mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 00:06:53 +00:00
331 lines
16 KiB
TypeScript
331 lines
16 KiB
TypeScript
// Reading PGP and S/MIME mail (#58): detect, decrypt, verify, and hand the
|
|
// reader a body and a status for the banner. Port of the plugins' render
|
|
// hooks: onRenderEmailBody in repos/plugins/bulwark-gpg/src/index.js and
|
|
// repos/plugins/smime/src/index.js. The raw message comes from the JMAP blob
|
|
// download of the email's blobId (the plugins' host.jmap.fetchBlob).
|
|
|
|
import type * as OpenPgp from 'openpgp';
|
|
import type { BodyPart, Email } from '../../api/types';
|
|
import { binaryToBytes, bytesToBinary, decodeBody, findNode, parseEntity, type MimeNode } from './mime';
|
|
import {
|
|
detectPgp, decryptMessage, verifyDetached, verifyCleartext, encryptionKeyIds, keyHasId, canonicalSignedBytes,
|
|
extractPublicKeyBlock, inspectKey, PgpArgon2UnsupportedError, type PgpSignatureSummary,
|
|
} from './pgp';
|
|
import type { SmimeKeyMaterial, SmimeSignatureStatus } from './smime/cms';
|
|
|
|
export type SecureKind = 'pgp' | 'smime';
|
|
export type SignatureState = 'none' | 'valid' | 'invalid' | 'unknown';
|
|
|
|
export interface SecureStatus {
|
|
kind: SecureKind;
|
|
encrypted: boolean;
|
|
decrypted: boolean;
|
|
/** Encrypted to a key of ours that is locked: ids of the keys to unlock. */
|
|
lockedKeyIds?: string[];
|
|
signature: SignatureState;
|
|
signerEmail?: string;
|
|
/** The signer's address differs from From. */
|
|
signerMismatch?: boolean;
|
|
/** S/MIME: self-signed signer certificate (not checked against a CA). */
|
|
selfSigned?: boolean;
|
|
/** Why the signature is invalid, or why decryption failed. */
|
|
error?: string;
|
|
/** Encrypted with an algorithm the app can't read (named). */
|
|
unsupportedAlgorithm?: string;
|
|
/** No key of ours can decrypt it. */
|
|
noKey?: boolean;
|
|
}
|
|
|
|
export interface SecureAttachment {
|
|
name: string;
|
|
type: string;
|
|
size: number;
|
|
bytes: Uint8Array;
|
|
}
|
|
|
|
/**
|
|
* A public key in the message for its From address. Never saved on its own:
|
|
* any sender can put a key in a message, so the banner offers it and only
|
|
* the user saves it (keyring savePgpKeyFor).
|
|
*/
|
|
export interface OfferedPgpKey {
|
|
armored: string;
|
|
fingerprint: string;
|
|
/** The From address, which one of the key's user ids names. */
|
|
email: string;
|
|
}
|
|
|
|
export interface SecureResult {
|
|
status: SecureStatus;
|
|
/** The decrypted/verified body, or null when there is none to show (locked, failed). */
|
|
body: { html: string | null; text: string | null } | null;
|
|
attachments: SecureAttachment[];
|
|
offeredKey?: OfferedPgpKey;
|
|
}
|
|
|
|
/** What the reader needs from the key store; injected so tests need no SecureStore. */
|
|
export interface SecureKeyAccess {
|
|
/** Unlocked private PGP keys. */
|
|
pgpDecryptionKeys(): OpenPgp.PrivateKey[];
|
|
/** Own PGP keys (unlocked or not): id + armored public key. */
|
|
pgpOwnKeys(): Array<{ id: string; publicKeyArmored: string; unlocked: boolean }>;
|
|
/** Every known PGP public key, to verify signatures. */
|
|
pgpPublicKeys(): string[];
|
|
/** Own S/MIME keys; `pkcs8` set when unlocked. */
|
|
smimeKeys(): SmimeKeyMaterial[];
|
|
/** Keep the certificate of a valid signature, if it names `fromEmail`. */
|
|
rememberCertificate?(der: Uint8Array, fromEmail: string): Promise<void>;
|
|
}
|
|
|
|
type PartLike = Pick<BodyPart, 'type'> & { subParts?: PartLike[] };
|
|
|
|
/**
|
|
* Cheap check on the email object (no download): could this be PGP or
|
|
* S/MIME? multipart/encrypted, multipart/signed with a pgp/pkcs7 signature,
|
|
* pkcs7-mime parts, or inline armor in the preview or text body.
|
|
*/
|
|
export function secureKindHint(email: Pick<Email, 'bodyStructure' | 'attachments' | 'preview' | 'textBody' | 'bodyValues'>): SecureKind | null {
|
|
const types: string[] = [];
|
|
const collect = (part: PartLike | undefined) => {
|
|
if (!part) return;
|
|
if (part.type) types.push(part.type.toLowerCase());
|
|
part.subParts?.forEach(collect);
|
|
};
|
|
collect(email.bodyStructure as PartLike | undefined);
|
|
(email.attachments ?? []).forEach((a) => types.push((a.type || '').toLowerCase()));
|
|
if (types.some((t) => t.includes('pkcs7-mime') || t.includes('pkcs7-signature'))) return 'smime';
|
|
if (types.some((t) => t === 'multipart/encrypted' || t === 'application/pgp-encrypted' || t === 'application/pgp-signature')) return 'pgp';
|
|
const text = [
|
|
email.preview ?? '',
|
|
...((email.textBody ?? []).map((p) => (p.partId ? email.bodyValues?.[p.partId]?.value ?? '' : ''))),
|
|
].join('\n');
|
|
if (/-----BEGIN PGP (MESSAGE|SIGNED MESSAGE)-----/.test(text)) return 'pgp';
|
|
return null;
|
|
}
|
|
|
|
/** A decrypted payload: a MIME entity, or (inline PGP) plain text. */
|
|
async function renderPayload(bytes: Uint8Array): Promise<{ body: SecureResult['body']; attachments: SecureAttachment[] }> {
|
|
const head = new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(0, 512));
|
|
const looksLikeMime = /^(?:[A-Za-z][A-Za-z0-9-]*:[^\r\n]*\r?\n(?:[ \t][^\r\n]*\r?\n)*)+\r?\n/.test(head) || /^content-type:/i.test(head);
|
|
if (!looksLikeMime) {
|
|
return { body: { html: null, text: new TextDecoder('utf-8', { fatal: false }).decode(bytes) }, attachments: [] };
|
|
}
|
|
const { default: PostalMime } = await import('postal-mime');
|
|
const parsed = await PostalMime.parse(bytes, { attachmentEncoding: 'arraybuffer' });
|
|
let html = parsed.html ?? null;
|
|
const attachments: SecureAttachment[] = [];
|
|
for (const att of parsed.attachments ?? []) {
|
|
const data = typeof att.content === 'string' ? new TextEncoder().encode(att.content) : new Uint8Array(att.content as ArrayBuffer);
|
|
const cid = att.contentId?.replace(/^<|>$/g, '');
|
|
if (cid && html && html.includes(`cid:${cid}`)) {
|
|
html = html.split(`cid:${cid}`).join(`data:${att.mimeType || 'application/octet-stream'};base64,${btoa(bytesToBinary(data))}`);
|
|
if (att.disposition === 'inline') continue;
|
|
}
|
|
attachments.push({ name: att.filename || 'attachment', type: att.mimeType || 'application/octet-stream', size: data.byteLength, bytes: data });
|
|
}
|
|
let text = parsed.text ?? null;
|
|
// Non-MIME content signed or encrypted by OpenSSL and older clients:
|
|
// never show a blank message.
|
|
if (!html && !(text && text.trim()) && attachments.length === 0) {
|
|
text = new TextDecoder('utf-8', { fatal: false }).decode(bytes).trim();
|
|
}
|
|
return { body: { html, text }, attachments };
|
|
}
|
|
|
|
function applyPgpSignature(status: SecureStatus, sig: PgpSignatureSummary, fromEmail?: string): void {
|
|
status.signature = sig.status;
|
|
if (sig.signerEmail) {
|
|
status.signerEmail = sig.signerEmail;
|
|
if (fromEmail && sig.status === 'valid' && sig.signerEmails && !sig.signerEmails.includes(fromEmail.toLowerCase())) status.signerMismatch = true;
|
|
}
|
|
if (sig.status === 'invalid' && sig.error) status.error = sig.error;
|
|
}
|
|
|
|
async function openPgp(raw: Uint8Array, fromEmail: string | undefined, keys: SecureKeyAccess): Promise<SecureResult | null> {
|
|
const info = detectPgp(raw);
|
|
if (info.kind === 'none') return null;
|
|
const status: SecureStatus = { kind: 'pgp', encrypted: false, decrypted: false, signature: 'none' };
|
|
const verificationKeys = keys.pgpPublicKeys();
|
|
try {
|
|
if (info.kind === 'pgp-mime-encrypted' || info.kind === 'inline-encrypted') {
|
|
status.encrypted = true;
|
|
const decryptionKeys = keys.pgpDecryptionKeys();
|
|
const ids = await encryptionKeyIds(info.ciphertext);
|
|
const own = keys.pgpOwnKeys();
|
|
const addressed: typeof own = [];
|
|
for (const k of own) if (await keyHasId(k.publicKeyArmored, ids)) addressed.push(k);
|
|
if (addressed.length === 0) {
|
|
status.noKey = true;
|
|
status.error = 'No PGP key of yours can decrypt this message';
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
if (!addressed.some((k) => k.unlocked) || decryptionKeys.length === 0) {
|
|
status.lockedKeyIds = addressed.map((k) => k.id);
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
const dec = await decryptMessage({ armoredMessage: info.ciphertext, decryptionKeys, verificationKeysArmored: verificationKeys });
|
|
status.decrypted = true;
|
|
// PGP/MIME may sign the inner entity again (multipart/signed inside).
|
|
let payload = dec.data;
|
|
let sig = dec.signature;
|
|
if (sig.status === 'none') {
|
|
const inner = detectPgp(dec.data);
|
|
if (inner.kind === 'pgp-mime-signed') {
|
|
sig = await verifySignedPart(inner.signedBytes, inner.signature, verificationKeys);
|
|
payload = inner.signedBytes;
|
|
}
|
|
}
|
|
applyPgpSignature(status, sig, fromEmail);
|
|
const rendered = await renderPayload(payload);
|
|
return withOfferedKey({ status, ...rendered }, rendered.body?.text, fromEmail);
|
|
}
|
|
if (info.kind === 'pgp-mime-signed') {
|
|
applyPgpSignature(status, await verifySignedPart(info.signedBytes, info.signature, verificationKeys), fromEmail);
|
|
return { status, ...(await renderPayload(info.signedBytes)) };
|
|
}
|
|
// inline-signed
|
|
const v = await verifyCleartext({ cleartext: info.cleartext, verificationKeysArmored: verificationKeys });
|
|
applyPgpSignature(status, v.signature, fromEmail);
|
|
return withOfferedKey({ status, body: { html: null, text: v.text }, attachments: [] }, v.text, fromEmail);
|
|
} catch (err) {
|
|
status.error = err instanceof PgpArgon2UnsupportedError ? err.message : err instanceof Error ? err.message : String(err);
|
|
if (status.encrypted) status.decrypted = false;
|
|
else if (status.signature === 'none') status.signature = 'invalid';
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
}
|
|
|
|
async function verifySignedPart(signed: Uint8Array, signature: string, verificationKeys: string[]): Promise<PgpSignatureSummary> {
|
|
const first = await verifyDetached({ data: signed, armoredSignature: signature, verificationKeysArmored: verificationKeys });
|
|
if (first.status !== 'invalid') return first;
|
|
// A relay that dropped the CRs: check the canonical form too.
|
|
const canonical = canonicalSignedBytes(signed);
|
|
if (canonical.length === signed.length) return first;
|
|
const retry = await verifyDetached({ data: canonical, armoredSignature: signature, verificationKeysArmored: verificationKeys });
|
|
return retry.status === 'valid' ? retry : first;
|
|
}
|
|
|
|
/**
|
|
* Offers a public key the text carries, only when one of its user ids is the
|
|
* From address and the signature (if any) did not fail. Nothing is stored
|
|
* here: the webmail's bulwark-gpg plugin imports keys only from its
|
|
* settings, and a key that arrived in a message would otherwise be used to
|
|
* verify and encrypt for whatever address it claims.
|
|
*/
|
|
async function withOfferedKey(result: SecureResult, text: string | null | undefined, fromEmail: string | undefined): Promise<SecureResult> {
|
|
const from = fromEmail?.trim().toLowerCase();
|
|
if (!from || result.status.signature === 'invalid') return result;
|
|
const block = extractPublicKeyBlock(text ?? '');
|
|
if (!block) return result;
|
|
try {
|
|
const info = await inspectKey(block);
|
|
if (info.isPrivate) return result;
|
|
if (!info.userIDs.some((u) => u.email.trim().toLowerCase() === from)) return result;
|
|
return { ...result, offeredKey: { armored: info.publicKeyArmored, fingerprint: info.fingerprint.toLowerCase(), email: from } };
|
|
} catch {
|
|
// a broken embedded key: nothing to offer
|
|
return result;
|
|
}
|
|
}
|
|
|
|
function applySmimeSignature(status: SecureStatus, sig: SmimeSignatureStatus): void {
|
|
status.signature = sig.signatureValid ? 'valid' : 'invalid';
|
|
status.signerEmail = sig.signerEmail;
|
|
status.selfSigned = sig.selfSigned;
|
|
if (sig.signerEmailMatch === false) status.signerMismatch = true;
|
|
if (!sig.signatureValid && sig.signatureError) status.error = sig.signatureError;
|
|
}
|
|
|
|
async function openSmime(raw: Uint8Array, fromEmail: string | undefined, keys: SecureKeyAccess): Promise<SecureResult | null> {
|
|
const root = parseEntity(bytesToBinary(raw));
|
|
const isDetached = (n: MimeNode) => n.type === 'multipart/signed' && /pkcs7-signature/i.test(n.params.protocol || '');
|
|
const isOpaque = (n: MimeNode) => n.type === 'application/pkcs7-mime' || n.type === 'application/x-pkcs7-mime';
|
|
const node = findNode(root, (n) => isDetached(n) || isOpaque(n));
|
|
if (!node) return null;
|
|
|
|
const cms = await import('./smime/cms');
|
|
const status: SecureStatus = { kind: 'smime', encrypted: false, decrypted: false, signature: 'none' };
|
|
try {
|
|
if (isDetached(node)) {
|
|
const sigPart = node.children.find((c) => /^application\/(x-)?pkcs7-signature$/.test(c.type));
|
|
const signed = node.children[0];
|
|
if (!sigPart || !signed || signed === sigPart) {
|
|
status.signature = 'invalid';
|
|
status.error = 'Malformed multipart/signed message';
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
const entity = binaryToBytes(signed.raw);
|
|
const sig = await cms.smimeVerifyDetached(decodeBody(sigPart), entity, fromEmail);
|
|
applySmimeSignature(status, sig);
|
|
if (sig.signatureValid && sig.signerCertificate && fromEmail) await keys.rememberCertificate?.(sig.signerCertificate, fromEmail);
|
|
return { status, ...(await renderPayload(entity)) };
|
|
}
|
|
|
|
const der = decodeBody(node);
|
|
if (cms.isSignedData(der)) {
|
|
const v = await cms.smimeVerifyOpaque(der, fromEmail);
|
|
applySmimeSignature(status, v.status);
|
|
if (v.status.signatureValid && v.status.signerCertificate && fromEmail) await keys.rememberCertificate?.(v.status.signerCertificate, fromEmail);
|
|
return { status, ...(await renderPayload(v.mimeBytes)) };
|
|
}
|
|
|
|
status.encrypted = true;
|
|
const material = keys.smimeKeys();
|
|
let decrypted: Uint8Array;
|
|
try {
|
|
decrypted = (await cms.smimeDecrypt(der, material)).mimeBytes;
|
|
} catch (err) {
|
|
if (err instanceof cms.SmimeKeyLockedError) {
|
|
status.lockedKeyIds = cms.matchingKeyIds(der, material).filter((id) => !material.find((m) => m.id === id)?.pkcs8);
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
if (err instanceof cms.SmimeNoMatchingKeyError) {
|
|
status.noKey = true;
|
|
status.error = err.message;
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
if (err instanceof cms.UnsupportedAlgorithmError) {
|
|
status.unsupportedAlgorithm = err.algorithm;
|
|
status.error = err.message;
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
throw err;
|
|
}
|
|
status.decrypted = true;
|
|
|
|
// Sign-then-encrypt: the decrypted content is a signed CMS, nested as a
|
|
// MIME entity (RFC 8551, Thunderbird/Outlook) or, rarely, raw DER.
|
|
let payload = decrypted;
|
|
const inner = parseEntity(bytesToBinary(decrypted.subarray(0, Math.min(decrypted.length, 1 << 20))));
|
|
const innerSigned = inner.type === 'application/pkcs7-mime' || inner.type === 'application/x-pkcs7-mime'
|
|
? decodeBody(parseEntity(bytesToBinary(decrypted)))
|
|
: decrypted[0] === 0x30 ? decrypted : null;
|
|
if (innerSigned && cms.isSignedData(innerSigned)) {
|
|
const v = await cms.smimeVerifyOpaque(innerSigned, fromEmail);
|
|
applySmimeSignature(status, v.status);
|
|
if (v.status.signatureValid && v.status.signerCertificate && fromEmail) await keys.rememberCertificate?.(v.status.signerCertificate, fromEmail);
|
|
payload = v.mimeBytes;
|
|
} else if (inner.type === 'multipart/signed' && /pkcs7-signature/i.test(inner.params.protocol || '')) {
|
|
const full = parseEntity(bytesToBinary(decrypted));
|
|
const sigPart = full.children.find((c) => /^application\/(x-)?pkcs7-signature$/.test(c.type));
|
|
const signed = full.children[0];
|
|
if (sigPart && signed) {
|
|
const entity = binaryToBytes(signed.raw);
|
|
const sig = await cms.smimeVerifyDetached(decodeBody(sigPart), entity, fromEmail);
|
|
applySmimeSignature(status, sig);
|
|
payload = entity;
|
|
}
|
|
}
|
|
return { status, ...(await renderPayload(payload)) };
|
|
} catch (err) {
|
|
status.error = err instanceof Error ? err.message : String(err);
|
|
if (!status.encrypted && status.signature === 'none') status.signature = 'invalid';
|
|
return { status, body: null, attachments: [] };
|
|
}
|
|
}
|
|
|
|
/** Open a raw message: PGP or S/MIME result, or null for an ordinary message. */
|
|
export async function openSecureMessage(raw: Uint8Array, fromEmail: string | undefined, keys: SecureKeyAccess): Promise<SecureResult | null> {
|
|
return (await openPgp(raw, fromEmail, keys)) ?? (await openSmime(raw, fromEmail, keys));
|
|
}
|