Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions smime/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "smime",
"name": "S/MIME",
"version": "1.0.2",
"version": "1.0.3",
"author": "Bulwark Mail Community",
"description": "End-to-end S/MIME for webmail: sign and encrypt outgoing messages, and automatically verify signatures and decrypt incoming CMS (PKCS#7) mail. Private keys are imported from a PKCS#12 (.p12/.pfx) file, encrypted at rest with a passphrase, and unlocked into non-extractable WebCrypto keys that never leave your browser. Runs in the privileged (same-origin) plugin tier so all cryptography happens locally with bundled pkijs/asn1js.",
"type": "ui-extension",
Expand Down Expand Up @@ -29,7 +29,10 @@
"label": "Content encryption algorithm",
"description": "Symmetric cipher used to encrypt the message body. AES-256-GCM is recommended; AES-128-GCM is slightly smaller and still strong.",
"default": "aes-256",
"options": ["aes-256", "aes-128"]
"options": [
"aes-256",
"aes-128"
]
},
"autoImportSignerCerts": {
"type": "boolean",
Expand All @@ -49,17 +52,5 @@
"description": "Show a caution banner when an incoming signature validates against a self-signed certificate (not chained to a trusted CA).",
"default": true
}
},
"locales": {
"en": {
"banner.signed_valid": "Signature valid",
"banner.signed_invalid": "Signature invalid",
"banner.encrypted": "Encrypted message",
"banner.decrypted": "Decrypted",
"banner.locked": "Encrypted — unlock your key to read",
"toolbar.sign": "Sign",
"toolbar.encrypt": "Encrypt",
"settings.title": "S/MIME keys & certificates"
}
}
}
4 changes: 2 additions & 2 deletions smime/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion smime/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bulwark-plugin-smime",
"version": "1.0.2",
"version": "1.0.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
Binary file modified smime/smime.zip
Binary file not shown.
1,730 changes: 1,659 additions & 71 deletions smime/src/index.js

Large diffs are not rendered by default.

142 changes: 142 additions & 0 deletions smime/src/mime-signed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Byte-exact extraction of the protected content and detached CMS signature
* from a multipart/signed (RFC 1847 / RFC 5751 §3.1) MIME entity.
*
* Detached S/MIME verification requires the EXACT transport-encoded octets
* of the first (protected) body part — including its own MIME headers and
* CRLF line endings — because that is what the signature was computed over
* (RFC 5751 §3.1.1). JMAP's decoded, per-part representation is NOT
* guaranteed to preserve this, so this module operates on the raw bytes of
* the whole multipart/signed section (as returned by host.jmap.fetchBlob for
* the top-level body), sliced at the MIME boundary ourselves — mirroring how
* the opaque path already treats blobs as "a full MIME part" in
* smime-decrypt.js:normalizeCmsBytes.
*/

/**
* @param {Uint8Array} raw Raw bytes of the multipart/signed body
* (boundary-delimited, as transmitted).
* @param {string} boundary The boundary parameter from this part's own
* Content-Type header (multipart/signed;
* boundary="...").
* @returns {{ contentBytes: Uint8Array, signatureBytes: Uint8Array }}
* contentBytes — part 1, canonicalised to CRLF, exactly as signed.
* signatureBytes — part 2's body, CTE-decoded to raw CMS DER bytes.
*/
export function splitMultipartSigned(raw, boundary) {
if (!boundary) {
throw new Error('multipart/signed: missing boundary parameter — cannot split parts');
}

const delim = new TextEncoder().encode(`--${boundary}`);
const offsets = findAll(raw, delim);
if (offsets.length < 2) {
throw new Error('multipart/signed: could not locate two MIME boundary delimiters');
}

// Part 1: right after the first delimiter line's CRLF, up to (but not
// including) the CRLF that immediately precedes the second delimiter — that
// CRLF belongs to the delimiter itself, not to the content (RFC 2046 §5.1.1).
const part1Start = skipDelimiterLineEnd(raw, offsets[0] + delim.length);
const part1End = stripTrailingLineBreak(raw, offsets[1]);

// Part 2: the pkcs7-signature part, up to the closing boundary (or EOF if
// the closing "--boundary--" wasn't included in what we fetched).
const part2Start = skipDelimiterLineEnd(raw, offsets[1] + delim.length);
const part2End = stripTrailingLineBreak(raw, offsets[2] !== undefined ? offsets[2] : raw.length);

const part1 = raw.slice(part1Start, part1End);
const part2 = raw.slice(part2Start, part2End);

return {
contentBytes: canonicalizeToCrlf(part1),
signatureBytes: decodeMimePartBody(part2),
};
}

// ── Byte search helpers ─────────────────────────────────────────────

function findAll(haystack, needle) {
const out = [];
let from = 0;
for (;;) {
const idx = indexOfBytes(haystack, needle, from);
if (idx === -1) break;
out.push(idx);
from = idx + needle.length;
}
return out;
}

function indexOfBytes(haystack, needle, from) {
outer: for (let i = from; i <= haystack.length - needle.length; i++) {
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) continue outer;
}
return i;
}
return -1;
}

function skipDelimiterLineEnd(bytes, offset) {
let i = offset;
if (bytes[i] === 0x0d) i++; // \r
if (bytes[i] === 0x0a) i++; // \n
return i;
}

function stripTrailingLineBreak(bytes, offset) {
if (offset >= 2 && bytes[offset - 2] === 0x0d && bytes[offset - 1] === 0x0a) return offset - 2;
if (offset >= 1 && bytes[offset - 1] === 0x0a) return offset - 1;
return offset;
}

/**
* RFC 5751 requires CRLF canonical form for the signed content. Most
* transports already deliver CRLF; some storage layers normalise to
* LF-only, which would otherwise break verification silently.
*/
function canonicalizeToCrlf(bytes) {
let hasLoneLf = false;
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0x0a && bytes[i - 1] !== 0x0d) { hasLoneLf = true; break; }
}
if (!hasLoneLf) return bytes;

const out = [];
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0x0a && bytes[i - 1] !== 0x0d) out.push(0x0d);
out.push(bytes[i]);
}
return new Uint8Array(out);
}

/** Parse the pkcs7-signature part's own header block and decode its CTE. */
function decodeMimePartBody(raw) {
const text = latin1String(raw);
const sep = text.match(/\r?\n\r?\n/);
const headerText = sep ? text.slice(0, sep.index) : '';
const bodyText = sep ? text.slice(sep.index + sep[0].length) : text;

const cteMatch = headerText.match(/content-transfer-encoding:\s*([^\r\n]+)/i);
const cte = (cteMatch ? cteMatch[1] : '7bit').trim().toLowerCase();

if (cte === 'base64') {
const cleaned = bodyText.replace(/[^A-Za-z0-9+/=]/g, '');
const bin = atob(cleaned);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}

// 7bit / 8bit / binary — body text is already a latin1 view of raw bytes.
const out = new Uint8Array(bodyText.length);
for (let i = 0; i < bodyText.length; i++) out[i] = bodyText.charCodeAt(i) & 0xff;
return out;
}

function latin1String(bytes) {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return s;
}
53 changes: 49 additions & 4 deletions smime/src/smime-detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ export function detectSmime(contentType, bodyStructure, attachments) {
}
}

if (ct.includes('multipart/signed') && ct.includes('application/pkcs7-signature')) {
return { type: 'detached-sig', supported: false };
if (ct.includes('multipart/signed') &&
(ct.includes('application/pkcs7-signature') || ct.includes('application/x-pkcs7-signature'))) {
const boundary = extractBoundary(contentType);
const parts = findDetachedSigParts(bodyStructure);
return {
type: 'detached-sig',
supported: !!boundary, // still refuse gracefully if we truly can't split it
boundary,
contentPart: parts?.contentPart,
sigPart: parts?.sigPart,
};
}
}

Expand Down Expand Up @@ -80,8 +89,16 @@ function walkBodyStructure(part) {
}

if (type === 'multipart/signed') {
if (part.subParts?.some((sp) => sp.type?.toLowerCase().includes('application/pkcs7-signature'))) {
return { type: 'detached-sig', supported: false };
const parts = findDetachedSigParts(part);
if (parts) {
const boundary = extractBoundary(part.type);
return {
type: 'detached-sig',
supported: !!boundary,
boundary,
contentPart: parts.contentPart,
sigPart: parts.sigPart,
};
}
}

Expand Down Expand Up @@ -110,6 +127,34 @@ function findCmsPart(bodyStructure, _smimeType) {
return null;
}

/** Extract the boundary= parameter from a raw Content-Type header string. */
function extractBoundary(contentType) {
if (!contentType) return null;
const m = contentType.match(/boundary\s*=\s*"?([^";]+)"?/i);
return m ? m[1] : null;
}

/**
* Given a multipart/signed bodyStructure node, find its two children: the
* protected content part and the application/pkcs7-signature part. Returns
* blobId/partId for each (used as a fallback reference; the actual byte-exact
* extraction happens on the raw parent blob via mime-signed.js, not on these
* individually-decoded child blobs — see index.js).
*/
function findDetachedSigParts(part) {
if (!part?.subParts) return null;
const sigPart = part.subParts.find((sp) => {
const t = sp.type?.toLowerCase() || '';
return t.includes('application/pkcs7-signature') || t.includes('application/x-pkcs7-signature');
});
if (!sigPart) return null;
const contentPart = part.subParts.find((sp) => sp !== sigPart);
return {
sigPart: { blobId: sigPart.blobId, partId: sigPart.partId },
contentPart: contentPart ? { blobId: contentPart.blobId, partId: contentPart.partId } : undefined,
};
}

function inferSmimeTypeFromContentType(ct) {
const lower = ct.toLowerCase();
if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data';
Expand Down
Loading