From be98acdc8b3e0f3ac494c09bd009be7483a2beb8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:13:25 +0100 Subject: [PATCH] test: send and read pgp and s/mime mail between two accounts on live stalwart #58 --- .../live-stalwart-secure-mail.test.ts | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/api/__tests__/live-stalwart-secure-mail.test.ts diff --git a/src/api/__tests__/live-stalwart-secure-mail.test.ts b/src/api/__tests__/live-stalwart-secure-mail.test.ts new file mode 100644 index 00000000..bdd9452f --- /dev/null +++ b/src/api/__tests__/live-stalwart-secure-mail.test.ts @@ -0,0 +1,193 @@ +/** + * Live checks of PGP and S/MIME mail (#58) against a real Stalwart: a + * signed and encrypted message is built the way the composer builds it, + * uploaded and sent with Email/import + EmailSubmission from the first + * login to the second, fetched there as the raw message and decrypted with + * the recipient's key, signature checked. The sender's Sent copy opens with + * the sender's key (encrypt-to-self). + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`, + * `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2` + * defaults to the first password). The keys are made fresh for the run. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * npx vitest run src/api/__tests__/live-stalwart-secure-mail.test.ts + * + * Every message the run sends is removed from both accounts. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { destroyEmails, getMailboxes, sendEmail } from '../email'; +import { getIdentities } from '../identity'; +import { uploadBytes } from '../blob'; +import { fetchBlobBytes } from '../../lib/email-export'; +import { buildSecureMessage } from '../../lib/crypto/secure-send'; +import { openSecureMessage, type SecureKeyAccess } from '../../lib/crypto/secure-message'; +import type { MimeMessageInput } from '../../lib/crypto/mime'; +import { makePgpKey, makeSmimeCert, useNodeCrypto } from '../../lib/crypto/__tests__/helpers'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS; +const live = SERVER && PASS && USER2 ? describe : describe.skip; + +const RUN_STARTED = Date.now(); +const TAG = `bulwark-secure-${RUN_STARTED.toString(36)}`; + +let jmap: JMAPClient; +let sender: JMAPClient; +let recipient: JMAPClient; + +const as = (client: JMAPClient) => jmap.restoreSnapshot(client.snapshot()); + +/** The raw bytes of the message with this subject in the recipient's account (waits for delivery). */ +async function receive(subject: string): Promise { + as(recipient); + for (let i = 0; i < 40; i++) { + const res = await jmap.request([ + ['Email/query', { accountId: jmap.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 100 }, '0'], + ['Email/get', { accountId: jmap.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'blobId'] }, '1'], + ]); + const hit = (res.methodResponses[1][1].list as Array<{ subject?: string; blobId: string }>).find((e) => e.subject === subject); + if (hit) return fetchBlobBytes(hit.blobId, 'message.eml', 'message/rfc822'); + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`"${subject}" did not arrive`); +} + +async function removeRunMail(client: JMAPClient): Promise { + as(client); + const res = await jmap.request([ + ['Email/query', { accountId: jmap.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'], + ['Email/get', { accountId: jmap.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'], + ]); + const ids = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }>).filter((e) => e.subject?.includes(TAG)).map((e) => e.id); + if (ids.length) await destroyEmails(ids); +} + +/** Send `raw` from the sender like the composer: upload, Email/import into Drafts, submit, file in Sent. */ +async function sendRaw(raw: Uint8Array, to: string): Promise { + as(sender); + const [identity] = await getIdentities(); + const boxes = await getMailboxes(); + const drafts = boxes.find((b) => b.role === 'drafts')!.id; + const sent = boxes.find((b) => b.role === 'sent')!.id; + const { blobId } = await uploadBytes(raw, 'message/rfc822'); + const result = await sendEmail( + { from: [{ email: identity.email }], to: [{ email: to }], subject: 'not used for a raw send' }, + identity.id, sent, undefined, { draftsMailboxId: drafts, rawBlobId: blobId }, + ); + expect(result.emailSubmissionId).toBeTruthy(); + return result.emailId!; +} + +async function sentCopy(emailId: string): Promise { + as(sender); + const res = await jmap.request([['Email/get', { accountId: jmap.accountId, ids: [emailId], properties: ['blobId', 'mailboxIds', 'keywords'] }, '0']]); + const [email] = res.methodResponses[0][1].list; + return fetchBlobBytes(email.blobId, 'message.eml', 'message/rfc822'); +} + +const message = (from: string, to: string, subject: string): MimeMessageInput => ({ + from: { name: 'Bulwark Live', email: from }, + to: [{ email: to }], + subject, + textBody: 'Geheime Grüße aus dem Live-Test', + htmlBody: '

Geheime Grüße aus dem Live-Test

', + attachments: [{ filename: 'live.txt', contentType: 'text/plain', content: new TextEncoder().encode('attachment') }], +}); + +live('live Stalwart: PGP and S/MIME mail', () => { + beforeAll(async () => { + useNodeCrypto(); + const mod = await import('../jmap-client'); + jmap = mod.jmapClient as unknown as JMAPClient; + sender = new JMAPClient(); + await sender.connect(SERVER!, USER, PASS); + recipient = new JMAPClient(); + await recipient.connect(SERVER!, USER2!, PASS2); + }, 60_000); + + afterAll(async () => { + await removeRunMail(sender).catch(() => undefined); + await removeRunMail(recipient).catch(() => undefined); + }, 60_000); + + it('sends a signed and encrypted PGP/MIME message that the recipient decrypts and verifies', async () => { + as(sender); + const [identity] = await getIdentities(); + const me = await makePgpKey(identity.email); + const them = await makePgpKey(USER2!); + const subject = `pgp ${TAG}`; + const raw = await buildSecureMessage(message(identity.email, USER2!, subject), { mode: 'pgp', sign: true, encrypt: true }, { + pgp: { signingKey: me.unlocked, encryptionKeys: [them.publicKey, me.publicKey] }, + }); + const emailId = await sendRaw(raw, USER2!); + + const received = await receive(subject); + expect(new TextDecoder().decode(received)).not.toContain('Geheime'); + const access: SecureKeyAccess = { + pgpDecryptionKeys: () => [them.unlocked], + pgpOwnKeys: () => [{ id: 'them', publicKeyArmored: them.publicKey, unlocked: true }], + pgpPublicKeys: () => [me.publicKey], + smimeKeys: () => [], + }; + const res = await openSecureMessage(received, identity.email, access); + expect(res?.status).toMatchObject({ kind: 'pgp', encrypted: true, decrypted: true, signature: 'valid', signerEmail: identity.email.toLowerCase() }); + expect(res?.body?.html).toContain('Grüße'); + expect(res?.attachments.map((a) => a.name)).toEqual(['live.txt']); + + // The Sent copy opens with the sender's own key. + const own = await openSecureMessage(await sentCopy(emailId), identity.email, { + ...access, pgpDecryptionKeys: () => [me.unlocked], pgpOwnKeys: () => [{ id: 'me', publicKeyArmored: me.publicKey, unlocked: true }], + }); + expect(own?.status.decrypted).toBe(true); + }, 120_000); + + it('sends a signed and encrypted S/MIME message that the recipient decrypts and verifies', async () => { + as(sender); + const [identity] = await getIdentities(); + const me = await makeSmimeCert(identity.email); + const them = await makeSmimeCert(USER2!); + const subject = `smime ${TAG}`; + const raw = await buildSecureMessage(message(identity.email, USER2!, subject), { mode: 'smime', sign: true, encrypt: true }, { + smime: { + signer: { pkcs8: me.pkcs8, certificate: me.certificate, chain: [], algorithm: 'RSA-2048' }, + recipientCerts: [them.certificate], + senderCert: me.certificate, + }, + }); + const emailId = await sendRaw(raw, USER2!); + + const received = await receive(subject); + const access = (cert: typeof me): SecureKeyAccess => ({ + pgpDecryptionKeys: () => [], pgpOwnKeys: () => [], pgpPublicKeys: () => [], + smimeKeys: () => [{ id: cert.email, certificate: cert.certificate, pkcs8: cert.pkcs8 }], + }); + const res = await openSecureMessage(received, identity.email, access(them)); + expect(res?.status).toMatchObject({ kind: 'smime', encrypted: true, decrypted: true, signature: 'valid', signerEmail: identity.email.toLowerCase() }); + expect(res?.body?.text).toContain('Grüße'); + expect(res?.attachments.map((a) => a.name)).toEqual(['live.txt']); + + const own = await openSecureMessage(await sentCopy(emailId), identity.email, access(me)); + expect(own?.status.decrypted).toBe(true); + }, 120_000); + + it('sends a detached S/MIME signature the recipient verifies', async () => { + as(sender); + const [identity] = await getIdentities(); + const me = await makeSmimeCert(identity.email); + const subject = `smime-signed ${TAG}`; + const raw = await buildSecureMessage(message(identity.email, USER2!, subject), { mode: 'smime', sign: true, encrypt: false }, { + smime: { signer: { pkcs8: me.pkcs8, certificate: me.certificate, chain: [], algorithm: 'RSA-2048' } }, + }); + await sendRaw(raw, USER2!); + const res = await openSecureMessage(await receive(subject), identity.email, { pgpDecryptionKeys: () => [], pgpOwnKeys: () => [], pgpPublicKeys: () => [], smimeKeys: () => [] }); + expect(res?.status).toMatchObject({ kind: 'smime', encrypted: false, signature: 'valid' }); + }, 120_000); +});