refactor: move S/MIME to generic crypto plugin hooks
This commit is contained in:
@@ -11,7 +11,6 @@ import { useFilterStore } from '@/stores/filter-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useSmimeStore } from '@/stores/smime-store';
|
||||
|
||||
// Minimal snapshot shapes - we only capture what we need
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -133,7 +132,6 @@ export function clearAllStores(): void {
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
useSmimeStore.getState().clearState();
|
||||
}
|
||||
|
||||
/** Evict cached state for one account */
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import {
|
||||
pemToDer,
|
||||
derToPem,
|
||||
isPem,
|
||||
parseCertificateDer,
|
||||
parseCertificatePemOrDer,
|
||||
computeFingerprint,
|
||||
classifyCapabilities,
|
||||
extractCertificateInfo,
|
||||
} from '../certificate-utils';
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
|
||||
// Generate a self-signed test certificate using Web Crypto + pkijs
|
||||
let testCertDer: ArrayBuffer;
|
||||
let testCert: pkijs.Certificate;
|
||||
let testKeyPair: globalThis.CryptoKeyPair;
|
||||
|
||||
beforeAll(async () => {
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
pkijs.setEngine('test', crypto, cryptoEngine);
|
||||
|
||||
// Generate RSA key pair
|
||||
testKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// Build a minimal self-signed X.509 certificate
|
||||
testCert = new pkijs.Certificate();
|
||||
testCert.version = 2; // v3
|
||||
testCert.serialNumber = new asn1js.Integer({ value: 1 });
|
||||
|
||||
testCert.issuer.typesAndValues.push(new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3', // CN
|
||||
value: new asn1js.Utf8String({ value: 'Test CA' }),
|
||||
}));
|
||||
|
||||
testCert.subject.typesAndValues.push(new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3', // CN
|
||||
value: new asn1js.Utf8String({ value: 'Test User' }),
|
||||
}));
|
||||
|
||||
testCert.subject.typesAndValues.push(new pkijs.AttributeTypeAndValue({
|
||||
type: '1.2.840.113549.1.9.1', // emailAddress
|
||||
value: new asn1js.IA5String({ value: 'test@example.com' }),
|
||||
}));
|
||||
|
||||
testCert.notBefore.value = new Date('2024-01-01T00:00:00Z');
|
||||
testCert.notAfter.value = new Date('2030-12-31T23:59:59Z');
|
||||
|
||||
await testCert.subjectPublicKeyInfo.importKey(testKeyPair.publicKey, cryptoEngine);
|
||||
|
||||
// Add KeyUsage extension: digitalSignature + keyEncipherment
|
||||
const bitArray = new ArrayBuffer(1);
|
||||
const bitView = new Uint8Array(bitArray);
|
||||
bitView[0] = 0b10100000; // digitalSignature (bit 0) + keyEncipherment (bit 2)
|
||||
|
||||
testCert.extensions = [
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.15', // keyUsage
|
||||
critical: true,
|
||||
extnValue: new asn1js.OctetString({
|
||||
valueHex: new Uint8Array(new asn1js.BitString({
|
||||
valueHex: bitArray,
|
||||
unusedBits: 3,
|
||||
}).toBER(false)),
|
||||
}).toBER(false) as ArrayBuffer,
|
||||
parsedValue: {
|
||||
digitalSignature: true,
|
||||
contentCommitment: false,
|
||||
keyEncipherment: true,
|
||||
dataEncipherment: false,
|
||||
keyAgreement: false,
|
||||
keyCertSign: false,
|
||||
cRLSign: false,
|
||||
encipherOnly: false,
|
||||
decipherOnly: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await testCert.sign(testKeyPair.privateKey, 'SHA-256', cryptoEngine);
|
||||
|
||||
// toBER may return a non-standard ArrayBuffer in jsdom; normalize it
|
||||
const rawDer = testCert.toSchema(true).toBER(false);
|
||||
testCertDer = new Uint8Array(rawDer).buffer;
|
||||
});
|
||||
|
||||
describe('certificate-utils', () => {
|
||||
describe('pemToDer / derToPem roundtrip', () => {
|
||||
it('converts PEM to DER and back', () => {
|
||||
const pem = derToPem(testCertDer, 'CERTIFICATE');
|
||||
expect(pem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(pem).toContain('-----END CERTIFICATE-----');
|
||||
|
||||
const der2 = pemToDer(pem);
|
||||
expect(new Uint8Array(der2)).toEqual(new Uint8Array(testCertDer));
|
||||
});
|
||||
|
||||
it('derToPem wraps lines at 64 chars', () => {
|
||||
const pem = derToPem(testCertDer, 'CERTIFICATE');
|
||||
const lines = pem.split('\n');
|
||||
// All content lines (not headers) should be <= 64 chars
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('-----')) {
|
||||
expect(line.length).toBeLessThanOrEqual(64);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPem', () => {
|
||||
it('returns true for certificate PEM', () => {
|
||||
expect(isPem('-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for PKCS12 PEM', () => {
|
||||
expect(isPem('-----BEGIN PKCS12-----\ndata\n-----END PKCS12-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for private key PEM', () => {
|
||||
expect(isPem('-----BEGIN PRIVATE KEY-----\ndata\n-----END PRIVATE KEY-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for encrypted private key PEM', () => {
|
||||
expect(isPem('-----BEGIN ENCRYPTED PRIVATE KEY-----\ndata\n-----END ENCRYPTED PRIVATE KEY-----')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-PEM data', () => {
|
||||
expect(isPem('hello world')).toBe(false);
|
||||
expect(isPem('')).toBe(false);
|
||||
expect(isPem('MIIB...')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCertificateDer', () => {
|
||||
it('parses a valid DER certificate', () => {
|
||||
const cert = parseCertificateDer(testCertDer);
|
||||
expect(cert).toBeInstanceOf(pkijs.Certificate);
|
||||
});
|
||||
|
||||
it('throws on invalid DER data', () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
expect(() => parseCertificateDer(garbage)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCertificatePemOrDer', () => {
|
||||
it('parses DER ArrayBuffer', () => {
|
||||
const cert = parseCertificatePemOrDer(testCertDer);
|
||||
expect(cert).toBeInstanceOf(pkijs.Certificate);
|
||||
});
|
||||
|
||||
it('parses PEM string', () => {
|
||||
const pem = derToPem(testCertDer, 'CERTIFICATE');
|
||||
const cert = parseCertificatePemOrDer(pem);
|
||||
expect(cert).toBeInstanceOf(pkijs.Certificate);
|
||||
});
|
||||
|
||||
it('throws on non-PEM string', () => {
|
||||
expect(() => parseCertificatePemOrDer('not a pem')).toThrow('String input is not PEM-encoded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeFingerprint', () => {
|
||||
it('returns hex fingerprint with colons', async () => {
|
||||
const fp = await computeFingerprint(testCertDer);
|
||||
expect(fp).toMatch(/^[0-9a-f]{2}(:[0-9a-f]{2}){31}$/);
|
||||
});
|
||||
|
||||
it('is deterministic', async () => {
|
||||
const fp1 = await computeFingerprint(testCertDer);
|
||||
const fp2 = await computeFingerprint(testCertDer);
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyCapabilities', () => {
|
||||
it('detects sign + encrypt from KeyUsage', () => {
|
||||
const caps = classifyCapabilities(testCert);
|
||||
expect(caps.canSign).toBe(true);
|
||||
expect(caps.canEncrypt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCertificateInfo', () => {
|
||||
it('extracts full certificate metadata', async () => {
|
||||
const info = await extractCertificateInfo(testCert, testCertDer);
|
||||
|
||||
expect(info.subject).toContain('CN=Test User');
|
||||
expect(info.issuer).toContain('CN=Test CA');
|
||||
expect(info.notBefore).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(info.notAfter).toBe('2030-12-31T23:59:59.000Z');
|
||||
expect(info.fingerprint).toMatch(/^[0-9a-f]{2}(:[0-9a-f]{2}){31}$/);
|
||||
expect(info.algorithm).toMatch(/^RSA/);
|
||||
expect(info.emailAddresses).toContain('test@example.com');
|
||||
expect(info.capabilities.canSign).toBe(true);
|
||||
expect(info.capabilities.canEncrypt).toBe(true);
|
||||
});
|
||||
|
||||
it('returns serialNumber as hex', async () => {
|
||||
const info = await extractCertificateInfo(testCert, testCertDer);
|
||||
// Serial number 1 → should be hex string
|
||||
expect(info.serialNumber).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
// Each test file gets a fresh global indexedDB via fake-indexeddb/auto.
|
||||
// Since openDB() caches connections implicitly, we re-import the module for each test.
|
||||
// However, to keep it simple, we'll just test in order and accept cumulative state,
|
||||
// or we can test with unique IDs.
|
||||
|
||||
import {
|
||||
saveKeyRecord,
|
||||
getKeyRecord,
|
||||
getKeyRecordForEmail,
|
||||
listKeyRecords,
|
||||
deleteKeyRecord,
|
||||
savePublicCert,
|
||||
getPublicCertForEmail,
|
||||
listPublicCerts,
|
||||
deletePublicCert,
|
||||
} from '../key-storage';
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from '../types';
|
||||
|
||||
function makeKeyRecord(overrides: Partial<SmimeKeyRecord> = {}): SmimeKeyRecord {
|
||||
return {
|
||||
id: 'key-1',
|
||||
email: 'user@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
certificateChain: [],
|
||||
encryptedPrivateKey: new ArrayBuffer(32),
|
||||
salt: new ArrayBuffer(16),
|
||||
iv: new ArrayBuffer(12),
|
||||
kdfIterations: 600000,
|
||||
issuer: 'CN=Test CA',
|
||||
subject: 'CN=Test User',
|
||||
serialNumber: '01',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'aa:bb:cc',
|
||||
algorithm: 'RSA-2048',
|
||||
capabilities: { canSign: true, canEncrypt: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePublicCert(overrides: Partial<SmimePublicCert> = {}): SmimePublicCert {
|
||||
return {
|
||||
id: 'cert-1',
|
||||
email: 'recipient@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: 'CN=Test CA',
|
||||
subject: 'CN=Recipient',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'dd:ee:ff',
|
||||
source: 'manual',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Use unique IDs for each test to avoid state leakage
|
||||
let testCounter = 0;
|
||||
function uid() { return `test-${++testCounter}-${Date.now()}`; }
|
||||
|
||||
describe('key-storage', () => {
|
||||
describe('key records', () => {
|
||||
it('saves and retrieves a key record by id', async () => {
|
||||
const id = uid();
|
||||
const record = makeKeyRecord({ id });
|
||||
await saveKeyRecord(record);
|
||||
const retrieved = await getKeyRecord(id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.id).toBe(id);
|
||||
expect(retrieved!.email).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent key record', async () => {
|
||||
const result = await getKeyRecord('absolutely-non-existent-' + uid());
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retrieves key record by email', async () => {
|
||||
const id = uid();
|
||||
const email = `alice-${id}@example.com`;
|
||||
const record = makeKeyRecord({ id, email });
|
||||
await saveKeyRecord(record);
|
||||
const result = await getKeyRecordForEmail(email);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.email).toBe(email);
|
||||
});
|
||||
|
||||
it('lists key records (includes previously saved)', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@example.com` }));
|
||||
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@example.com` }));
|
||||
const records = await listKeyRecords();
|
||||
expect(records.length).toBeGreaterThanOrEqual(2);
|
||||
expect(records.find(r => r.id === id1)).toBeDefined();
|
||||
expect(records.find(r => r.id === id2)).toBeDefined();
|
||||
});
|
||||
|
||||
it('deletes a key record', async () => {
|
||||
const id = uid();
|
||||
const record = makeKeyRecord({ id });
|
||||
await saveKeyRecord(record);
|
||||
await deleteKeyRecord(id);
|
||||
const result = await getKeyRecord(id);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates existing record with same id', async () => {
|
||||
const id = uid();
|
||||
const record1 = makeKeyRecord({ id, email: 'old@example.com' });
|
||||
await saveKeyRecord(record1);
|
||||
const record2 = makeKeyRecord({ id, email: 'new@example.com' });
|
||||
await saveKeyRecord(record2);
|
||||
const retrieved = await getKeyRecord(id);
|
||||
expect(retrieved!.email).toBe('new@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public certs', () => {
|
||||
it('saves and retrieves by email', async () => {
|
||||
const id = uid();
|
||||
const email = `recipient-${id}@example.com`;
|
||||
const cert = makePublicCert({ id, email });
|
||||
await savePublicCert(cert);
|
||||
const result = await getPublicCertForEmail(email);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.email).toBe(email);
|
||||
});
|
||||
|
||||
it('lists public certs (includes previously saved)', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@test.com` }));
|
||||
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@test.com` }));
|
||||
const certs = await listPublicCerts();
|
||||
expect(certs.find(c => c.id === id1)).toBeDefined();
|
||||
expect(certs.find(c => c.id === id2)).toBeDefined();
|
||||
});
|
||||
|
||||
it('deletes a public cert', async () => {
|
||||
const id = uid();
|
||||
const cert = makePublicCert({ id });
|
||||
await savePublicCert(cert);
|
||||
await deletePublicCert(id);
|
||||
const certs = await listPublicCerts();
|
||||
expect(certs.find(c => c.id === id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountId filtering', () => {
|
||||
it('listKeyRecords filters by accountId', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||
|
||||
const acct1Records = await listKeyRecords('acct-1');
|
||||
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||
expect(acct1Records.find(r => r.id === id2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('listKeyRecords includes records without accountId when filtering', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` }));
|
||||
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||
|
||||
const acct1Records = await listKeyRecords('acct-1');
|
||||
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||
expect(acct1Records.find(r => r.id === id2)).toBeDefined();
|
||||
});
|
||||
|
||||
it('listPublicCerts filters by accountId', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||
|
||||
const acct1Certs = await listPublicCerts('acct-1');
|
||||
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||
expect(acct1Certs.find(c => c.id === id2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('listPublicCerts includes certs without accountId when filtering', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` }));
|
||||
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||
|
||||
const acct1Certs = await listPublicCerts('acct-1');
|
||||
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||
expect(acct1Certs.find(c => c.id === id2)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { buildMimeMessage, quotedPrintableEncode, base64Encode } from '../mime-builder';
|
||||
|
||||
// Mock crypto.randomUUID and crypto.getRandomValues for deterministic tests
|
||||
beforeEach(() => {
|
||||
let uuidCounter = 0;
|
||||
vi.spyOn(crypto, 'randomUUID').mockImplementation(
|
||||
() => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, '0')}` as `${string}-${string}-${string}-${string}-${string}`,
|
||||
);
|
||||
|
||||
vi.spyOn(crypto, 'getRandomValues').mockImplementation(<T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array) {
|
||||
const u8 = new Uint8Array((array as unknown as Uint8Array).buffer);
|
||||
for (let i = 0; i < u8.length; i++) u8[i] = i;
|
||||
}
|
||||
return array;
|
||||
});
|
||||
});
|
||||
|
||||
describe('mime-builder', () => {
|
||||
describe('buildMimeMessage', () => {
|
||||
it('builds a text-only message', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { name: 'Alice', email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Hello',
|
||||
textBody: 'Hi Bob!',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('From: "Alice" <alice@example.com>');
|
||||
expect(text).toContain('To: bob@example.com');
|
||||
expect(text).toContain('Subject: Hello');
|
||||
expect(text).toContain('Content-Type: text/plain; charset=utf-8');
|
||||
expect(text).toContain('MIME-Version: 1.0');
|
||||
expect(text).toContain('Hi Bob!');
|
||||
});
|
||||
|
||||
it('builds a text + HTML multipart/alternative', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Test',
|
||||
textBody: 'Plain text',
|
||||
htmlBody: '<p>HTML body</p>',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: multipart/alternative');
|
||||
expect(text).toContain('Content-Type: text/plain; charset=utf-8');
|
||||
expect(text).toContain('Content-Type: text/html; charset=utf-8');
|
||||
expect(text).toContain('Plain text');
|
||||
expect(text).toContain('<p>HTML body</p>');
|
||||
});
|
||||
|
||||
it('builds HTML-only message', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'HTML only',
|
||||
htmlBody: '<h1>Hello</h1>',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: text/html; charset=utf-8');
|
||||
expect(text).toContain('<h1>Hello</h1>');
|
||||
});
|
||||
|
||||
it('builds message with attachments', () => {
|
||||
const attachment = {
|
||||
filename: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
content: new TextEncoder().encode('file content').buffer,
|
||||
};
|
||||
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'With attachment',
|
||||
textBody: 'See attached',
|
||||
attachments: [attachment],
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: multipart/mixed');
|
||||
expect(text).toContain('Content-Disposition: attachment; filename="test.txt"');
|
||||
expect(text).toContain('Content-Transfer-Encoding: base64');
|
||||
});
|
||||
|
||||
it('builds message with inline attachment (cid)', () => {
|
||||
const inline = {
|
||||
filename: 'image.png',
|
||||
contentType: 'image/png',
|
||||
content: new Uint8Array([0x89, 0x50, 0x4E, 0x47]).buffer,
|
||||
cid: 'img1',
|
||||
};
|
||||
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Inline',
|
||||
htmlBody: '<img src="cid:img1">',
|
||||
attachments: [inline],
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Disposition: inline; filename="image.png"');
|
||||
expect(text).toContain('Content-ID: <img1>');
|
||||
});
|
||||
|
||||
it('includes CC header when provided', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
cc: [{ name: 'Charlie', email: 'charlie@example.com' }],
|
||||
subject: 'CC test',
|
||||
textBody: 'Hello',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Cc: "Charlie" <charlie@example.com>');
|
||||
});
|
||||
|
||||
it('omits BCC from MIME headers', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
bcc: [{ email: 'secret@example.com' }],
|
||||
subject: 'BCC test',
|
||||
textBody: 'Hello',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).not.toContain('Bcc');
|
||||
expect(text).not.toContain('secret@example.com');
|
||||
});
|
||||
|
||||
it('includes In-Reply-To and References', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Re: Thread',
|
||||
textBody: 'reply',
|
||||
inReplyTo: '<msg1@example.com>',
|
||||
references: ['<msg0@example.com>', '<msg1@example.com>'],
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('In-Reply-To: <msg1@example.com>');
|
||||
expect(text).toContain('References: <msg0@example.com> <msg1@example.com>');
|
||||
});
|
||||
|
||||
it('encodes non-ASCII subject with RFC 2047', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Ünïcödé',
|
||||
textBody: 'test',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('=?UTF-8?Q?');
|
||||
});
|
||||
|
||||
it('uses CRLF line endings', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'CRLF',
|
||||
textBody: 'test',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
// Should contain CRLF before the body
|
||||
expect(text).toContain('\r\n');
|
||||
// Should not contain bare LF without preceding CR (except within QP encoding)
|
||||
const lines = text.split('\r\n');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('builds empty body message', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { email: 'alice@example.com' },
|
||||
to: [{ email: 'bob@example.com' }],
|
||||
subject: 'Empty',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('Content-Type: text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
it('escapes display name in From header', () => {
|
||||
const msg = buildMimeMessage({
|
||||
from: { name: 'O\'Brien, "Bob"', email: 'bob@example.com' },
|
||||
to: [{ email: 'alice@example.com' }],
|
||||
subject: 'Name test',
|
||||
textBody: 'test',
|
||||
date: new Date('2024-06-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const text = new TextDecoder().decode(msg);
|
||||
expect(text).toContain('From: "O\'Brien, \\"Bob\\"" <bob@example.com>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('quotedPrintableEncode', () => {
|
||||
it('passes through ASCII text unchanged', () => {
|
||||
const result = quotedPrintableEncode('Hello World');
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('encodes non-ASCII characters', () => {
|
||||
const result = quotedPrintableEncode('Héllo');
|
||||
expect(result).toContain('=');
|
||||
});
|
||||
|
||||
it('encodes equals sign', () => {
|
||||
const result = quotedPrintableEncode('a=b');
|
||||
expect(result).toContain('=3D');
|
||||
});
|
||||
|
||||
it('wraps long lines with soft line break', () => {
|
||||
const longLine = 'a'.repeat(100);
|
||||
const result = quotedPrintableEncode(longLine);
|
||||
const lines = result.split('\r\n');
|
||||
for (const line of lines) {
|
||||
expect(line.length).toBeLessThanOrEqual(76);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64Encode', () => {
|
||||
it('encodes binary data to base64', () => {
|
||||
const data = new Uint8Array([72, 101, 108, 108, 111]).buffer; // "Hello"
|
||||
const result = base64Encode(data);
|
||||
expect(result).toBe('SGVsbG8=');
|
||||
});
|
||||
|
||||
it('wraps long lines at 76 chars', () => {
|
||||
const data = new Uint8Array(200).buffer;
|
||||
const result = base64Encode(data);
|
||||
const lines = result.split('\r\n');
|
||||
for (const line of lines) {
|
||||
expect(line.length).toBeLessThanOrEqual(76);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { importPkcs12, unlockPrivateKey, decryptPrivateKeyBytes } from '../pkcs12-import';
|
||||
import { exportPkcs12 } from '../pkcs12-export';
|
||||
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
|
||||
function stringToAB(str: string): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal real PKCS#12 (.p12) blob for testing.
|
||||
*/
|
||||
async function buildTestP12(
|
||||
email: string,
|
||||
cn: string,
|
||||
p12Password: string,
|
||||
): Promise<{ p12Bytes: ArrayBuffer; keyPair: globalThis.CryptoKeyPair; certDer: ArrayBuffer }> {
|
||||
// Generate RSA key pair (signing)
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// Self-signed certificate
|
||||
const cert = new pkijs.Certificate();
|
||||
cert.version = 2;
|
||||
cert.serialNumber = new asn1js.Integer({ value: 42 });
|
||||
|
||||
cert.issuer.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '1.2.840.113549.1.9.1',
|
||||
value: new asn1js.IA5String({ value: email }),
|
||||
}),
|
||||
);
|
||||
cert.notBefore.value = new Date('2024-01-01T00:00:00Z');
|
||||
cert.notAfter.value = new Date('2030-12-31T23:59:59Z');
|
||||
|
||||
await cert.subjectPublicKeyInfo.importKey(keyPair.publicKey, cryptoEngine);
|
||||
await cert.sign(keyPair.privateKey, 'SHA-256', cryptoEngine);
|
||||
|
||||
const certDer = cert.toSchema(true).toBER(false);
|
||||
|
||||
// Export private key as PKCS#8
|
||||
const pkcs8Bytes = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
|
||||
// Build PKCS#12 structure
|
||||
const keyBag = new pkijs.PKCS8ShroudedKeyBag({
|
||||
parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes),
|
||||
});
|
||||
|
||||
const passwordBuf = stringToAB(p12Password);
|
||||
|
||||
await keyBag.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
contentEncryptionAlgorithm: {
|
||||
name: 'AES-CBC',
|
||||
length: 256,
|
||||
} as Parameters<typeof keyBag.makeInternalValues>[0]['contentEncryptionAlgorithm'],
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
iterationCount: 2048,
|
||||
});
|
||||
|
||||
const keyBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.2',
|
||||
bagValue: keyBag,
|
||||
});
|
||||
|
||||
const certBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.3',
|
||||
bagValue: new pkijs.CertBag({ parsedValue: cert }),
|
||||
});
|
||||
|
||||
const authenticatedSafe = new pkijs.AuthenticatedSafe({
|
||||
parsedValue: {
|
||||
safeContents: [
|
||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [keyBagSafe] }) },
|
||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [certBagSafe] }) },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await authenticatedSafe.makeInternalValues({ safeContents: [{}, {}] });
|
||||
|
||||
const pfx = new pkijs.PFX({
|
||||
parsedValue: {
|
||||
integrityMode: 0,
|
||||
authenticatedSafe,
|
||||
},
|
||||
});
|
||||
|
||||
await pfx.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
iterations: 2048,
|
||||
pbkdf2HashAlgorithm: 'SHA-256',
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
});
|
||||
|
||||
const p12Bytes = pfx.toSchema().toBER(false);
|
||||
return { p12Bytes, keyPair, certDer };
|
||||
}
|
||||
|
||||
let testP12: Awaited<ReturnType<typeof buildTestP12>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
pkijs.setEngine('test', crypto, cryptoEngine);
|
||||
testP12 = await buildTestP12('alice@example.com', 'Alice Test', 'p12pass');
|
||||
});
|
||||
|
||||
describe('importPkcs12', () => {
|
||||
it('imports a valid PKCS#12 file and produces a key record', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
|
||||
expect(result.keyRecord).toBeDefined();
|
||||
expect(result.keyRecord.email).toBe('alice@example.com');
|
||||
expect(result.keyRecord.subject).toContain('Alice Test');
|
||||
expect(result.keyRecord.certificate).toBeDefined();
|
||||
expect(result.keyRecord.encryptedPrivateKey.byteLength).toBeGreaterThan(0);
|
||||
expect(result.keyRecord.salt.byteLength).toBeGreaterThan(0);
|
||||
expect(result.keyRecord.iv.byteLength).toBeGreaterThan(0);
|
||||
expect(result.keyRecord.kdfIterations).toBe(600_000);
|
||||
expect(result.keyRecord.fingerprint).toBeTruthy();
|
||||
|
||||
expect(result.certInfo).toBeDefined();
|
||||
expect(result.certInfo.emailAddresses).toContain('alice@example.com');
|
||||
});
|
||||
|
||||
it('throws on invalid ASN.1 data', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
await expect(importPkcs12(garbage, 'pass', 'store')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockPrivateKey', () => {
|
||||
it('unlocks and returns signing and decryption keys', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
const { signingKey, decryptionKey } = await unlockPrivateKey(result.keyRecord, 'storagepass');
|
||||
|
||||
expect(signingKey).toBeDefined();
|
||||
expect(signingKey.type).toBe('private');
|
||||
expect(signingKey.extractable).toBe(false);
|
||||
|
||||
expect(decryptionKey).toBeDefined();
|
||||
expect(decryptionKey!.type).toBe('private');
|
||||
expect(decryptionKey!.extractable).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on incorrect passphrase', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
await expect(unlockPrivateKey(result.keyRecord, 'wrongpass')).rejects.toThrow('Incorrect passphrase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptPrivateKeyBytes', () => {
|
||||
it('returns raw PKCS#8 bytes', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
const pkcs8 = await decryptPrivateKeyBytes(result.keyRecord, 'storagepass');
|
||||
|
||||
expect(pkcs8).toBeInstanceOf(ArrayBuffer);
|
||||
expect(pkcs8.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws on incorrect passphrase', async () => {
|
||||
const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
await expect(decryptPrivateKeyBytes(result.keyRecord, 'bad')).rejects.toThrow('Incorrect passphrase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportPkcs12', () => {
|
||||
it('produces a valid PKCS#12 that can be re-imported', async () => {
|
||||
const imported = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
|
||||
// Export
|
||||
const p12Out = await exportPkcs12(imported.keyRecord, 'storagepass', 'exportpass');
|
||||
expect(p12Out).toBeInstanceOf(ArrayBuffer);
|
||||
expect(p12Out.byteLength).toBeGreaterThan(0);
|
||||
|
||||
// Re-import
|
||||
const reimported = await importPkcs12(p12Out, 'exportpass', 'newstoragepass');
|
||||
expect(reimported.keyRecord.email).toBe('alice@example.com');
|
||||
expect(reimported.keyRecord.subject).toContain('Alice Test');
|
||||
expect(reimported.keyRecord.fingerprint).toBe(imported.keyRecord.fingerprint);
|
||||
});
|
||||
|
||||
it('throws on incorrect storage passphrase', async () => {
|
||||
const imported = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass');
|
||||
await expect(exportPkcs12(imported.keyRecord, 'wrong', 'exportpass')).rejects.toThrow('Incorrect passphrase');
|
||||
});
|
||||
});
|
||||
@@ -1,361 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { smimeSign } from '../smime-sign';
|
||||
import { smimeEncrypt } from '../smime-encrypt';
|
||||
import { smimeDecrypt, SmimeKeyLockedError, findDecryptionCandidates, normalizeCmsBytes } from '../smime-decrypt';
|
||||
import { smimeVerify } from '../smime-verify';
|
||||
import { extractCertificateInfo } from '../certificate-utils';
|
||||
import type { SmimeKeyRecord } from '../types';
|
||||
|
||||
// ─── KNOWN ISSUE: skipped (pre-existing, not a logical test failure) ──────────
|
||||
// This suite OOMs its Vitest worker: during the encrypt/decrypt roundtrip the
|
||||
// heap climbs past ~4 GB and the worker dies with
|
||||
// "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of
|
||||
// memory". The cause is excessive allocation in the S/MIME crypto path
|
||||
// (real 2048-bit RSA via pkijs/asn1js under the Node webcrypto engine), not the
|
||||
// assertions themselves. It reproduces on main, independent of any branch.
|
||||
//
|
||||
// Skipped so the rest of the suite stays green and CI workers don't crash.
|
||||
// To work on it: flip the flag below to false and run only this file, e.g.
|
||||
// npx vitest run lib/smime/__tests__/smime-crypto.test.ts
|
||||
// Likely directions: investigate the pkijs CMS allocation growth / retained
|
||||
// buffers, reuse a single generated key set, or split into smaller cases.
|
||||
const SKIP_SMIME_CRYPTO_OOM = true;
|
||||
const describeSmime = SKIP_SMIME_CRYPTO_OOM ? describe.skip : describe;
|
||||
|
||||
/**
|
||||
* Integration tests for S/MIME sign→verify and encrypt→decrypt roundtrips.
|
||||
* Uses Node.js crypto (not jsdom) for accurate Web Crypto behavior.
|
||||
*/
|
||||
|
||||
const testMimeBytes = new TextEncoder().encode(
|
||||
'Content-Type: text/plain; charset=utf-8\r\n\r\nHello, World!',
|
||||
);
|
||||
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
|
||||
async function buildCert(
|
||||
cn: string,
|
||||
email: string,
|
||||
publicKey: CryptoKey,
|
||||
signingPrivateKey: CryptoKey,
|
||||
): Promise<{ cert: pkijs.Certificate; certDer: ArrayBuffer }> {
|
||||
const cert = new pkijs.Certificate();
|
||||
cert.version = 2;
|
||||
cert.serialNumber = new asn1js.Integer({ value: Math.floor(Math.random() * 100000) });
|
||||
|
||||
cert.issuer.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3',
|
||||
value: new asn1js.Utf8String({ value: cn }),
|
||||
}),
|
||||
);
|
||||
cert.subject.typesAndValues.push(
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '1.2.840.113549.1.9.1',
|
||||
value: new asn1js.IA5String({ value: email }),
|
||||
}),
|
||||
);
|
||||
cert.notBefore.value = new Date('2024-01-01T00:00:00Z');
|
||||
cert.notAfter.value = new Date('2030-12-31T23:59:59Z');
|
||||
|
||||
await cert.subjectPublicKeyInfo.importKey(publicKey, cryptoEngine);
|
||||
|
||||
await cert.sign(signingPrivateKey, 'SHA-256', cryptoEngine);
|
||||
|
||||
const certDer = cert.toSchema(true).toBER(false);
|
||||
return { cert, certDer };
|
||||
}
|
||||
|
||||
async function makeKeyRecord(
|
||||
id: string,
|
||||
email: string,
|
||||
certDer: ArrayBuffer,
|
||||
): Promise<SmimeKeyRecord> {
|
||||
const cert = new pkijs.Certificate({
|
||||
schema: asn1js.fromBER(certDer).result,
|
||||
});
|
||||
const info = await extractCertificateInfo(cert, certDer);
|
||||
return {
|
||||
id,
|
||||
email: email.toLowerCase(),
|
||||
certificate: certDer,
|
||||
certificateChain: [],
|
||||
encryptedPrivateKey: new ArrayBuffer(0),
|
||||
salt: new ArrayBuffer(0),
|
||||
iv: new ArrayBuffer(0),
|
||||
kdfIterations: 600000,
|
||||
issuer: info.issuer,
|
||||
subject: info.subject,
|
||||
serialNumber: info.serialNumber,
|
||||
notBefore: info.notBefore,
|
||||
notAfter: info.notAfter,
|
||||
fingerprint: info.fingerprint,
|
||||
algorithm: info.algorithm,
|
||||
capabilities: info.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
// Signing key pair and cert (RSASSA-PKCS1-v1_5 public key embedded in cert)
|
||||
let signKeyPair: globalThis.CryptoKeyPair;
|
||||
let signCertDer: ArrayBuffer;
|
||||
|
||||
// Encryption key pair and cert (RSA-OAEP public key embedded in cert)
|
||||
let encKeyPair: globalThis.CryptoKeyPair;
|
||||
let encCertDer: ArrayBuffer;
|
||||
let encKeyRecord: SmimeKeyRecord;
|
||||
|
||||
// Second encryption identity for cross-recipient tests
|
||||
let bobEncKeyPair: globalThis.CryptoKeyPair;
|
||||
let bobEncCertDer: ArrayBuffer;
|
||||
let bobKeyRecord: SmimeKeyRecord;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Suite is skipped (see SKIP_SMIME_CRYPTO_OOM); bail before the expensive RSA
|
||||
// key generation so the skipped file stays fast.
|
||||
if (SKIP_SMIME_CRYPTO_OOM) return;
|
||||
pkijs.setEngine('test', crypto, cryptoEngine);
|
||||
|
||||
// --- Signing identity ---
|
||||
signKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
const signResult = await buildCert('Alice Signer', 'alice@example.com', signKeyPair.publicKey, signKeyPair.privateKey);
|
||||
signCertDer = signResult.certDer;
|
||||
|
||||
// --- Encryption identity (Alice) ---
|
||||
encKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'],
|
||||
);
|
||||
// Self-sign with a temporary signing key
|
||||
const tempSignKey = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
const encResult = await buildCert('Alice', 'alice@example.com', encKeyPair.publicKey, tempSignKey.privateKey);
|
||||
encCertDer = encResult.certDer;
|
||||
encKeyRecord = await makeKeyRecord('key-alice-enc', 'alice@example.com', encCertDer);
|
||||
|
||||
// --- Bob encryption identity ---
|
||||
bobEncKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'],
|
||||
);
|
||||
const bobTempSignKey = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
const bobResult = await buildCert('Bob', 'bob@example.com', bobEncKeyPair.publicKey, bobTempSignKey.privateKey);
|
||||
bobEncCertDer = bobResult.certDer;
|
||||
bobKeyRecord = await makeKeyRecord('key-bob-enc', 'bob@example.com', bobEncCertDer);
|
||||
});
|
||||
|
||||
describeSmime('smimeSign + smimeVerify roundtrip', () => {
|
||||
it('signs and verifies a message successfully', async () => {
|
||||
const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer);
|
||||
expect(signedBlob).toBeInstanceOf(Blob);
|
||||
expect(signedBlob.type).toContain('application/pkcs7-mime');
|
||||
|
||||
const cmsBytes = await signedBlob.arrayBuffer();
|
||||
const result = await smimeVerify(cmsBytes, 'alice@example.com');
|
||||
|
||||
expect(result.status.isSigned).toBe(true);
|
||||
expect(result.status.signatureValid).toBe(true);
|
||||
expect(result.status.signerEmailMatch).toBe(true);
|
||||
expect(result.status.signerCert).toBeDefined();
|
||||
expect(result.status.signerCert!.email).toBe('alice@example.com');
|
||||
|
||||
const innerText = new TextDecoder().decode(result.mimeBytes);
|
||||
expect(innerText).toContain('Hello, World!');
|
||||
});
|
||||
|
||||
it('reports email mismatch when From differs from signer', async () => {
|
||||
const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer);
|
||||
const cmsBytes = await signedBlob.arrayBuffer();
|
||||
const result = await smimeVerify(cmsBytes, 'evil@attacker.com');
|
||||
|
||||
expect(result.status.isSigned).toBe(true);
|
||||
expect(result.status.signerEmailMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describeSmime('smimeEncrypt + smimeDecrypt roundtrip', () => {
|
||||
it('encrypts and decrypts a message', async () => {
|
||||
const encryptedBlob = await smimeEncrypt(
|
||||
testMimeBytes,
|
||||
[encCertDer],
|
||||
encCertDer,
|
||||
);
|
||||
expect(encryptedBlob).toBeInstanceOf(Blob);
|
||||
expect(encryptedBlob.type).toContain('application/pkcs7-mime');
|
||||
|
||||
const cmsBytes = await encryptedBlob.arrayBuffer();
|
||||
const unlockedKeys = new Map<string, CryptoKey>();
|
||||
unlockedKeys.set(encKeyRecord.id, encKeyPair.privateKey);
|
||||
|
||||
const result = await smimeDecrypt({
|
||||
cmsBytes,
|
||||
keyRecords: [encKeyRecord],
|
||||
unlockedKeys,
|
||||
});
|
||||
|
||||
expect(result.mimeBytes).toBeDefined();
|
||||
const decryptedText = new TextDecoder().decode(result.mimeBytes);
|
||||
expect(decryptedText).toContain('Hello, World!');
|
||||
expect(result.keyRecordId).toBe(encKeyRecord.id);
|
||||
});
|
||||
|
||||
it('throws when no matching key is available', async () => {
|
||||
const encryptedBlob = await smimeEncrypt(
|
||||
testMimeBytes,
|
||||
[encCertDer],
|
||||
encCertDer,
|
||||
);
|
||||
const cmsBytes = await encryptedBlob.arrayBuffer();
|
||||
|
||||
// Bob's key record doesn't match Alice's encrypted message
|
||||
await expect(
|
||||
smimeDecrypt({
|
||||
cmsBytes,
|
||||
keyRecords: [bobKeyRecord],
|
||||
unlockedKeys: new Map(),
|
||||
}),
|
||||
).rejects.toThrow('No imported S/MIME key matches');
|
||||
});
|
||||
});
|
||||
|
||||
describeSmime('SmimeKeyLockedError', () => {
|
||||
it('has correct name and keyRecordId', () => {
|
||||
const err = new SmimeKeyLockedError('test', 'key-1');
|
||||
expect(err.name).toBe('SmimeKeyLockedError');
|
||||
expect(err.keyRecordId).toBe('key-1');
|
||||
expect(err.message).toBe('test');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describeSmime('findDecryptionCandidates', () => {
|
||||
it('returns empty array for invalid CMS data', () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
const result = findDecryptionCandidates(garbage, [encKeyRecord]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describeSmime('smimeVerify edge cases', () => {
|
||||
it('throws on invalid ASN.1 data', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
await expect(smimeVerify(garbage)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describeSmime('normalizeCmsBytes', () => {
|
||||
// Helper: a minimal DER-encoded ASN.1 SEQUENCE (0x30 tag)
|
||||
const derBytes = new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x05]);
|
||||
|
||||
it('passes through raw DER unchanged', () => {
|
||||
const result = new Uint8Array(normalizeCmsBytes(derBytes.buffer as ArrayBuffer));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('passes through empty buffer unchanged', () => {
|
||||
const result = normalizeCmsBytes(new ArrayBuffer(0));
|
||||
expect(result.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it('decodes plain base64 content', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const input = new TextEncoder().encode(b64).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes base64 content with MIME headers', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const mime =
|
||||
'Content-Type: application/pkcs7-mime\r\n' +
|
||||
'Content-Transfer-Encoding: base64\r\n' +
|
||||
'\r\n' +
|
||||
b64 + '\r\n';
|
||||
const input = new TextEncoder().encode(mime).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes PEM-wrapped content', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const pem = '-----BEGIN PKCS7-----\n' + b64 + '\n-----END PKCS7-----\n';
|
||||
const input = new TextEncoder().encode(pem).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes MIME headers with unix line endings', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const mime =
|
||||
'Content-Type: application/pkcs7-mime\n' +
|
||||
'Content-Transfer-Encoding: base64\n' +
|
||||
'\n' +
|
||||
b64 + '\n';
|
||||
const input = new TextEncoder().encode(mime).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('decodes base64 when MIME headers are very long', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const longHeader = 'X-Long-Header: ' + 'A'.repeat(3000) + '\r\n';
|
||||
const mime =
|
||||
longHeader +
|
||||
'Content-Type: application/pkcs7-mime\r\n' +
|
||||
'Content-Transfer-Encoding: base64\r\n' +
|
||||
'\r\n' +
|
||||
b64 + '\r\n';
|
||||
const input = new TextEncoder().encode(mime).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('extracts largest base64 block from multipart-like text', () => {
|
||||
const b64 = btoa(String.fromCharCode(...derBytes));
|
||||
const multipartLike =
|
||||
'Content-Type: multipart/mixed; boundary="b"\r\n\r\n' +
|
||||
'--b\r\n' +
|
||||
'Content-Type: text/plain\r\n\r\n' +
|
||||
'hello\r\n' +
|
||||
'--b\r\n' +
|
||||
'Content-Type: application/pkcs7-mime\r\n' +
|
||||
'Content-Transfer-Encoding: base64\r\n\r\n' +
|
||||
b64 + '\r\n' +
|
||||
'--b--\r\n';
|
||||
const input = new TextEncoder().encode(multipartLike).buffer as ArrayBuffer;
|
||||
const result = new Uint8Array(normalizeCmsBytes(input));
|
||||
expect(result).toEqual(derBytes);
|
||||
});
|
||||
|
||||
it('returns original when content is not decodable', () => {
|
||||
const garbage = new Uint8Array([0x01, 0x02, 0xFF, 0xFE]);
|
||||
const result = normalizeCmsBytes(garbage.buffer as ArrayBuffer);
|
||||
// Should return original since it can\'t be decoded
|
||||
expect(result.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectSmime } from '../smime-detect';
|
||||
|
||||
describe('detectSmime', () => {
|
||||
describe('no S/MIME content', () => {
|
||||
it('returns null type when no arguments provided', () => {
|
||||
const result = detectSmime();
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null type for plain text content', () => {
|
||||
const result = detectSmime('text/plain');
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null type for multipart/mixed without S/MIME', () => {
|
||||
const result = detectSmime('multipart/mixed; boundary="abc"');
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content-Type header detection', () => {
|
||||
it('detects enveloped-data from Content-Type', () => {
|
||||
const ct = 'application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"';
|
||||
const body = { partId: '1', blobId: 'blob1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('blob1');
|
||||
expect(result.partId).toBe('1');
|
||||
});
|
||||
|
||||
it('detects signed-data from Content-Type', () => {
|
||||
const ct = 'application/pkcs7-mime; smime-type=signed-data; name="smime.p7m"';
|
||||
const body = { partId: '2', blobId: 'blob2', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('signed-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('blob2');
|
||||
});
|
||||
|
||||
it('detects x-pkcs7-mime variant', () => {
|
||||
const ct = 'application/x-pkcs7-mime; smime-type=enveloped-data';
|
||||
const body = { partId: '1', blobId: 'blob1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
|
||||
it('detects detached signature via multipart/signed', () => {
|
||||
const ct = 'multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256';
|
||||
const result = detectSmime(ct);
|
||||
expect(result.type).toBe('detached-sig');
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('handles generic pkcs7-mime without smime-type', () => {
|
||||
const ct = 'application/pkcs7-mime; name="smime.p7m"';
|
||||
const body = { partId: '1', blobId: 'blob1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
// Should default to enveloped-data for generic pkcs7-mime
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.blobId).toBe('blob1');
|
||||
});
|
||||
|
||||
it('is case-insensitive for Content-Type', () => {
|
||||
const ct = 'Application/PKCS7-MIME; smime-type=Enveloped-Data';
|
||||
const body = { partId: '1', blobId: 'b1', type: ct };
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyStructure detection', () => {
|
||||
it('finds pkcs7-mime part in bodyStructure tree', () => {
|
||||
const body = {
|
||||
type: 'multipart/mixed',
|
||||
subParts: [
|
||||
{ partId: '1', type: 'text/plain', blobId: 'text-blob' },
|
||||
{
|
||||
partId: '2',
|
||||
type: 'application/pkcs7-mime; smime-type=enveloped-data',
|
||||
blobId: 'cms-blob',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = detectSmime(undefined, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('cms-blob');
|
||||
expect(result.partId).toBe('2');
|
||||
});
|
||||
|
||||
it('detects detached sig in multipart/signed bodyStructure', () => {
|
||||
const body = {
|
||||
type: 'multipart/signed',
|
||||
subParts: [
|
||||
{ partId: '1', type: 'text/plain', blobId: 'text-blob' },
|
||||
{ partId: '2', type: 'application/pkcs7-signature', blobId: 'sig-blob' },
|
||||
],
|
||||
};
|
||||
const result = detectSmime(undefined, body);
|
||||
expect(result.type).toBe('detached-sig');
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('walks nested bodyStructure', () => {
|
||||
const body = {
|
||||
type: 'multipart/mixed',
|
||||
subParts: [
|
||||
{
|
||||
type: 'multipart/alternative',
|
||||
subParts: [
|
||||
{ partId: '1.1', type: 'text/plain', blobId: 'txt' },
|
||||
{ partId: '1.2', type: 'text/html', blobId: 'html' },
|
||||
],
|
||||
},
|
||||
{
|
||||
partId: '2',
|
||||
type: 'application/pkcs7-mime; smime-type=signed-data',
|
||||
blobId: 'sig-blob',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = detectSmime(undefined, body);
|
||||
expect(result.type).toBe('signed-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('sig-blob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachment detection', () => {
|
||||
it('detects .p7m attachment', () => {
|
||||
const attachments = [
|
||||
{ partId: '3', blobId: 'att-blob', name: 'message.p7m', type: 'application/octet-stream' },
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.blobId).toBe('att-blob');
|
||||
});
|
||||
|
||||
it('detects .p7s attachment as detached-sig', () => {
|
||||
const attachments = [
|
||||
{ partId: '3', blobId: 'sig-blob', name: 'smime.p7s', type: 'application/octet-stream' },
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBe('detached-sig');
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('detects pkcs7-mime attachment type', () => {
|
||||
const attachments = [
|
||||
{
|
||||
partId: '2',
|
||||
blobId: 'enc-blob',
|
||||
name: 'encrypted.bin',
|
||||
type: 'application/pkcs7-mime; smime-type=enveloped-data',
|
||||
},
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
|
||||
it('skips non-S/MIME attachments', () => {
|
||||
const attachments = [
|
||||
{ partId: '2', blobId: 'pdf-blob', name: 'document.pdf', type: 'application/pdf' },
|
||||
];
|
||||
const result = detectSmime(undefined, null, attachments);
|
||||
expect(result.type).toBeNull();
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority order', () => {
|
||||
it('Content-Type takes precedence over bodyStructure', () => {
|
||||
const ct = 'application/pkcs7-mime; smime-type=enveloped-data';
|
||||
const body = {
|
||||
partId: '1',
|
||||
blobId: 'from-ct',
|
||||
type: ct,
|
||||
};
|
||||
const result = detectSmime(ct, body);
|
||||
expect(result.type).toBe('enveloped-data');
|
||||
expect(result.blobId).toBe('from-ct');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,360 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock IndexedDB storage functions before importing store
|
||||
vi.mock('@/lib/smime/key-storage', () => ({
|
||||
saveKeyRecord: vi.fn().mockResolvedValue(undefined),
|
||||
listKeyRecords: vi.fn().mockResolvedValue([]),
|
||||
deleteKeyRecord: vi.fn().mockResolvedValue(undefined),
|
||||
savePublicCert: vi.fn().mockResolvedValue(undefined),
|
||||
listPublicCerts: vi.fn().mockResolvedValue([]),
|
||||
deletePublicCert: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/smime/pkcs12-import', () => ({
|
||||
importPkcs12: vi.fn(),
|
||||
unlockPrivateKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/smime/certificate-utils', () => ({
|
||||
parseCertificatePemOrDer: vi.fn(),
|
||||
extractCertificateInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useSmimeStore } from '@/stores/smime-store';
|
||||
import { listKeyRecords, listPublicCerts, saveKeyRecord, deleteKeyRecord, deletePublicCert } from '@/lib/smime/key-storage';
|
||||
import { importPkcs12, unlockPrivateKey } from '@/lib/smime/pkcs12-import';
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types';
|
||||
|
||||
const mockKeyRecord: SmimeKeyRecord = {
|
||||
id: 'key-1',
|
||||
email: 'user@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
certificateChain: [],
|
||||
encryptedPrivateKey: new ArrayBuffer(32),
|
||||
salt: new ArrayBuffer(16),
|
||||
iv: new ArrayBuffer(12),
|
||||
kdfIterations: 600000,
|
||||
issuer: 'CN=Test CA',
|
||||
subject: 'CN=Test User',
|
||||
serialNumber: '01',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'aa:bb:cc',
|
||||
algorithm: 'RSA-2048',
|
||||
capabilities: { canSign: true, canEncrypt: true },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
// Reset store state
|
||||
useSmimeStore.setState({
|
||||
keyRecords: [],
|
||||
publicCerts: [],
|
||||
unlockedKeys: new Map(),
|
||||
unlockedDecryptionKeys: new Map(),
|
||||
identityKeyBindings: {},
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
autoImportSignerCerts: true,
|
||||
accountPreferences: {},
|
||||
currentAccountId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('smime-store', () => {
|
||||
describe('load', () => {
|
||||
it('loads key records and public certs from IndexedDB', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
const certs: SmimePublicCert[] = [];
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue(certs);
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
const state = useSmimeStore.getState();
|
||||
expect(state.keyRecords).toEqual(records);
|
||||
expect(state.publicCerts).toEqual(certs);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-unlock keys on load (security: no persisted passphrases)', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
// Simulate a stale legacy entry written by an older build.
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(unlockPrivateKey).not.toHaveBeenCalled();
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
vi.mocked(listKeyRecords).mockRejectedValue(new Error('DB failed'));
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(useSmimeStore.getState().error).toBe('DB failed');
|
||||
expect(useSmimeStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPKCS12', () => {
|
||||
it('imports and adds key record', async () => {
|
||||
vi.mocked(importPkcs12).mockResolvedValue({
|
||||
keyRecord: mockKeyRecord,
|
||||
certInfo: {} as unknown as import('@/lib/smime/types').CertificateInfo,
|
||||
});
|
||||
|
||||
const result = await useSmimeStore.getState().importPKCS12(
|
||||
new ArrayBuffer(10),
|
||||
'p12pass',
|
||||
'storagepass',
|
||||
);
|
||||
|
||||
expect(result.id).toBe('key-1');
|
||||
expect(saveKeyRecord).toHaveBeenCalledWith(mockKeyRecord);
|
||||
expect(useSmimeStore.getState().keyRecords).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sets error on import failure', async () => {
|
||||
vi.mocked(importPkcs12).mockRejectedValue(new Error('Bad password'));
|
||||
|
||||
await expect(
|
||||
useSmimeStore.getState().importPKCS12(new ArrayBuffer(10), 'wrong', 'pass'),
|
||||
).rejects.toThrow('Bad password');
|
||||
|
||||
expect(useSmimeStore.getState().error).toBe('Bad password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeKeyRecord', () => {
|
||||
it('removes key record and clears bindings', async () => {
|
||||
useSmimeStore.setState({
|
||||
keyRecords: [mockKeyRecord],
|
||||
identityKeyBindings: { 'identity-1': 'key-1' },
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
});
|
||||
|
||||
await useSmimeStore.getState().removeKeyRecord('key-1');
|
||||
|
||||
expect(deleteKeyRecord).toHaveBeenCalledWith('key-1');
|
||||
expect(useSmimeStore.getState().keyRecords).toHaveLength(0);
|
||||
expect(useSmimeStore.getState().identityKeyBindings).toEqual({});
|
||||
expect(useSmimeStore.getState().unlockedKeys.has('key-1')).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePublicCert', () => {
|
||||
it('removes public cert', async () => {
|
||||
const cert: SmimePublicCert = {
|
||||
id: 'cert-1',
|
||||
email: 'recipient@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: 'CN=CA',
|
||||
subject: 'CN=Recipient',
|
||||
notBefore: '2024-01-01T00:00:00Z',
|
||||
notAfter: '2030-12-31T23:59:59Z',
|
||||
fingerprint: 'aa:bb',
|
||||
source: 'manual',
|
||||
};
|
||||
useSmimeStore.setState({ publicCerts: [cert] });
|
||||
|
||||
await useSmimeStore.getState().removePublicCert('cert-1');
|
||||
|
||||
expect(deletePublicCert).toHaveBeenCalledWith('cert-1');
|
||||
expect(useSmimeStore.getState().publicCerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockKey + lockKey', () => {
|
||||
it('unlocks a key', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
const mockDecryptionKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey, decryptionKey: mockDecryptionKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(true);
|
||||
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
|
||||
});
|
||||
|
||||
it('never persists the passphrase to sessionStorage', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores only the signing key when no decryption key is available', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('throws for non-existent key record', async () => {
|
||||
await expect(
|
||||
useSmimeStore.getState().unlockKey('non-existent', 'pass'),
|
||||
).rejects.toThrow('Key record not found');
|
||||
});
|
||||
|
||||
it('locks a key', () => {
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
});
|
||||
|
||||
useSmimeStore.getState().lockKey('key-1');
|
||||
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('locks all keys', () => {
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([
|
||||
['key-1', {} as CryptoKey],
|
||||
['key-2', {} as CryptoKey],
|
||||
]),
|
||||
unlockedDecryptionKeys: new Map([
|
||||
['key-1', {} as CryptoKey],
|
||||
['key-2', {} as CryptoKey],
|
||||
]),
|
||||
});
|
||||
|
||||
useSmimeStore.getState().lockAllKeys();
|
||||
|
||||
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity bindings', () => {
|
||||
it('binds an identity to a key', () => {
|
||||
useSmimeStore.getState().bindIdentityToKey('identity-1', 'key-1');
|
||||
expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBe('key-1');
|
||||
});
|
||||
|
||||
it('unbinds an identity', () => {
|
||||
useSmimeStore.setState({ identityKeyBindings: { 'identity-1': 'key-1' } });
|
||||
useSmimeStore.getState().bindIdentityToKey('identity-1', null);
|
||||
expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getKeyRecordForIdentity returns the bound record', () => {
|
||||
useSmimeStore.setState({
|
||||
keyRecords: [mockKeyRecord],
|
||||
identityKeyBindings: { 'identity-1': 'key-1' },
|
||||
});
|
||||
|
||||
const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-1');
|
||||
expect(record?.id).toBe('key-1');
|
||||
});
|
||||
|
||||
it('getKeyRecordForIdentity returns undefined for unbound identity', () => {
|
||||
const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-2');
|
||||
expect(record).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicCertForEmail', () => {
|
||||
it('finds cert by email (case-insensitive)', () => {
|
||||
const cert: SmimePublicCert = {
|
||||
id: 'c1',
|
||||
email: 'bob@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: 'CN=CA',
|
||||
subject: 'CN=Bob',
|
||||
notBefore: '2024-01-01',
|
||||
notAfter: '2030-12-31',
|
||||
fingerprint: 'ff',
|
||||
source: 'manual',
|
||||
};
|
||||
useSmimeStore.setState({ publicCerts: [cert] });
|
||||
|
||||
expect(useSmimeStore.getState().getPublicCertForEmail('Bob@Example.COM')?.id).toBe('c1');
|
||||
});
|
||||
|
||||
it('returns undefined when not found', () => {
|
||||
expect(useSmimeStore.getState().getPublicCertForEmail('nobody@test.com')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecipientCerts', () => {
|
||||
it('partitions emails into found and missing', () => {
|
||||
const cert: SmimePublicCert = {
|
||||
id: 'c1',
|
||||
email: 'known@example.com',
|
||||
certificate: new ArrayBuffer(10),
|
||||
issuer: '',
|
||||
subject: '',
|
||||
notBefore: '',
|
||||
notAfter: '',
|
||||
fingerprint: '',
|
||||
source: 'manual',
|
||||
};
|
||||
useSmimeStore.setState({ publicCerts: [cert] });
|
||||
|
||||
const { found, missing } = useSmimeStore.getState().getRecipientCerts([
|
||||
'known@example.com',
|
||||
'unknown@example.com',
|
||||
]);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].id).toBe('c1');
|
||||
expect(missing).toEqual(['unknown@example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preferences', () => {
|
||||
it('sets sign default for identity', () => {
|
||||
useSmimeStore.getState().setSignDefault('identity-1', true);
|
||||
expect(useSmimeStore.getState().defaultSignIdentity['identity-1']).toBe(true);
|
||||
});
|
||||
|
||||
it('sets encrypt default', () => {
|
||||
useSmimeStore.getState().setEncryptDefault(true);
|
||||
expect(useSmimeStore.getState().defaultEncrypt).toBe(true);
|
||||
});
|
||||
|
||||
it('wipes any legacy persisted passphrases on module load', () => {
|
||||
// Module already loaded by the import above; simulate a stale entry and
|
||||
// re-import to confirm the cleanup runs. We use the same key the legacy
|
||||
// build used and assert it stays absent because the store's module-level
|
||||
// cleanup has already executed.
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets auto import signer certs', () => {
|
||||
useSmimeStore.getState().setAutoImportSignerCerts(true);
|
||||
expect(useSmimeStore.getState().autoImportSignerCerts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setError', () => {
|
||||
it('sets and clears error', () => {
|
||||
useSmimeStore.getState().setError('Something went wrong');
|
||||
expect(useSmimeStore.getState().error).toBe('Something went wrong');
|
||||
|
||||
useSmimeStore.getState().setError(null);
|
||||
expect(useSmimeStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { Convert } from 'pvtsutils';
|
||||
import type { CertificateInfo, SmimeKeyCapabilities } from './types';
|
||||
|
||||
/** OID for id-kp-emailProtection (S/MIME) */
|
||||
const OID_EMAIL_PROTECTION = '1.3.6.1.5.5.7.3.4';
|
||||
|
||||
/** OID for SubjectAlternativeName */
|
||||
const OID_SAN = '2.5.29.17';
|
||||
|
||||
// ── PEM/DER conversions ──────────────────────────────────────────────
|
||||
|
||||
export function pemToDer(pem: string): ArrayBuffer {
|
||||
const lines = pem
|
||||
.replace(/-----BEGIN [^-]+-----/, '')
|
||||
.replace(/-----END [^-]+-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
return Convert.FromBase64(lines);
|
||||
}
|
||||
|
||||
export function derToPem(der: ArrayBuffer, label: string): string {
|
||||
const b64 = Convert.ToBase64(der);
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < b64.length; i += 64) {
|
||||
lines.push(b64.slice(i, i + 64));
|
||||
}
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
|
||||
}
|
||||
|
||||
export function isPem(data: string): boolean {
|
||||
return /-----BEGIN (CERTIFICATE|PKCS12|ENCRYPTED PRIVATE KEY|PRIVATE KEY)-----/.test(data);
|
||||
}
|
||||
|
||||
// ── Certificate parsing ──────────────────────────────────────────────
|
||||
|
||||
export function parseCertificateDer(der: ArrayBuffer): pkijs.Certificate {
|
||||
const asn1 = asn1js.fromBER(der);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid DER data: ASN.1 parsing failed');
|
||||
}
|
||||
return new pkijs.Certificate({ schema: asn1.result });
|
||||
}
|
||||
|
||||
export function parseCertificatePemOrDer(data: ArrayBuffer | string): pkijs.Certificate {
|
||||
if (typeof data === 'string') {
|
||||
if (isPem(data)) {
|
||||
return parseCertificateDer(pemToDer(data));
|
||||
}
|
||||
throw new Error('String input is not PEM-encoded');
|
||||
}
|
||||
// ArrayBuffer might contain PEM text rather than DER binary
|
||||
// PEM files start with "-----BEGIN " (0x2D 0x2D 0x2D 0x2D 0x2D 0x42)
|
||||
const header = new Uint8Array(data, 0, Math.min(20, data.byteLength));
|
||||
const maybePem = String.fromCharCode(...header);
|
||||
if (maybePem.startsWith('-----BEGIN ')) {
|
||||
const text = new TextDecoder().decode(data);
|
||||
return parseCertificateDer(pemToDer(text));
|
||||
}
|
||||
return parseCertificateDer(data);
|
||||
}
|
||||
|
||||
// ── Metadata extraction ──────────────────────────────────────────────
|
||||
|
||||
function rdnToString(rdn: pkijs.RelativeDistinguishedNames): string {
|
||||
return rdn.typesAndValues
|
||||
.map((tv) => {
|
||||
const oid = tv.type;
|
||||
const val = tv.value.valueBlock.value;
|
||||
const name = oidToName(oid);
|
||||
return `${name}=${val}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function oidToName(oid: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'2.5.4.3': 'CN',
|
||||
'2.5.4.6': 'C',
|
||||
'2.5.4.7': 'L',
|
||||
'2.5.4.8': 'ST',
|
||||
'2.5.4.10': 'O',
|
||||
'2.5.4.11': 'OU',
|
||||
'1.2.840.113549.1.9.1': 'E',
|
||||
};
|
||||
return map[oid] ?? oid;
|
||||
}
|
||||
|
||||
export async function computeFingerprint(der: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new Uint8Array(der));
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(':');
|
||||
}
|
||||
|
||||
function extractAlgorithm(cert: pkijs.Certificate): string {
|
||||
const algOid = cert.subjectPublicKeyInfo.algorithm.algorithmId;
|
||||
// RSA
|
||||
if (algOid === '1.2.840.113549.1.1.1') {
|
||||
const pubKey = cert.subjectPublicKeyInfo;
|
||||
try {
|
||||
const asn1Pub = asn1js.fromBER(pubKey.subjectPublicKey.valueBlock.valueHexView);
|
||||
const seq = asn1Pub.result as asn1js.Sequence;
|
||||
const modulus = seq.valueBlock.value[0] as asn1js.Integer;
|
||||
const bitLen = (modulus.valueBlock.valueHexView.byteLength - 1) * 8;
|
||||
return `RSA-${bitLen}`;
|
||||
} catch {
|
||||
return 'RSA';
|
||||
}
|
||||
}
|
||||
// ECDSA
|
||||
if (algOid === '1.2.840.10045.2.1') {
|
||||
const params = cert.subjectPublicKeyInfo.algorithm.algorithmParams;
|
||||
if (params instanceof asn1js.ObjectIdentifier) {
|
||||
const curveOid = params.valueBlock.toString();
|
||||
const curves: Record<string, string> = {
|
||||
'1.2.840.10045.3.1.7': 'ECDSA-P256',
|
||||
'1.3.132.0.34': 'ECDSA-P384',
|
||||
'1.3.132.0.35': 'ECDSA-P521',
|
||||
};
|
||||
return curves[curveOid] ?? 'ECDSA';
|
||||
}
|
||||
return 'ECDSA';
|
||||
}
|
||||
return algOid;
|
||||
}
|
||||
|
||||
function extractKeyUsage(cert: pkijs.Certificate): string[] | undefined {
|
||||
const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.15');
|
||||
if (!ext?.parsedValue) return undefined;
|
||||
const ku = ext.parsedValue as {
|
||||
digitalSignature?: boolean;
|
||||
contentCommitment?: boolean;
|
||||
keyEncipherment?: boolean;
|
||||
dataEncipherment?: boolean;
|
||||
keyAgreement?: boolean;
|
||||
keyCertSign?: boolean;
|
||||
cRLSign?: boolean;
|
||||
encipherOnly?: boolean;
|
||||
decipherOnly?: boolean;
|
||||
};
|
||||
const names: string[] = [];
|
||||
if (ku.digitalSignature) names.push('digitalSignature');
|
||||
if (ku.contentCommitment) names.push('contentCommitment');
|
||||
if (ku.keyEncipherment) names.push('keyEncipherment');
|
||||
if (ku.dataEncipherment) names.push('dataEncipherment');
|
||||
if (ku.keyAgreement) names.push('keyAgreement');
|
||||
if (ku.keyCertSign) names.push('keyCertSign');
|
||||
if (ku.cRLSign) names.push('cRLSign');
|
||||
if (ku.encipherOnly) names.push('encipherOnly');
|
||||
if (ku.decipherOnly) names.push('decipherOnly');
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractExtendedKeyUsage(cert: pkijs.Certificate): string[] | undefined {
|
||||
const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.37');
|
||||
if (!ext?.parsedValue) return undefined;
|
||||
const eku = ext.parsedValue as pkijs.ExtKeyUsage;
|
||||
return eku.keyPurposes;
|
||||
}
|
||||
|
||||
function extractEmailAddresses(cert: pkijs.Certificate): string[] {
|
||||
const emails: string[] = [];
|
||||
|
||||
// From subject emailAddress attribute
|
||||
for (const tv of cert.subject.typesAndValues) {
|
||||
if (tv.type === '1.2.840.113549.1.9.1') {
|
||||
emails.push(tv.value.valueBlock.value as string);
|
||||
}
|
||||
}
|
||||
|
||||
// From SubjectAlternativeName
|
||||
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
|
||||
if (sanExt) {
|
||||
let names: pkijs.GeneralName[] | undefined;
|
||||
|
||||
// parsedValue may be a GeneralNames with .names, or a raw ASN.1 object
|
||||
const pv = sanExt.parsedValue as pkijs.GeneralNames | undefined;
|
||||
if (pv?.names) {
|
||||
names = pv.names;
|
||||
} else if (sanExt.extnValue) {
|
||||
// Manually parse the extension value as a SEQUENCE OF GeneralName
|
||||
try {
|
||||
const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView);
|
||||
if (sanAsn1.offset !== -1) {
|
||||
const gn = new pkijs.GeneralNames({ schema: sanAsn1.result });
|
||||
names = gn.names;
|
||||
}
|
||||
} catch {
|
||||
// Malformed SAN - skip gracefully
|
||||
}
|
||||
}
|
||||
|
||||
if (names) {
|
||||
for (const name of names) {
|
||||
// type 1 = rfc822Name
|
||||
if (name.type === 1 && typeof name.value === 'string') {
|
||||
if (!emails.includes(name.value)) {
|
||||
emails.push(name.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
/** Determine signing/encryption capabilities from KU / EKU. Tolerant of absent extensions. */
|
||||
export function classifyCapabilities(cert: pkijs.Certificate): SmimeKeyCapabilities {
|
||||
const ku = extractKeyUsage(cert);
|
||||
const eku = extractExtendedKeyUsage(cert);
|
||||
|
||||
let canSign = true;
|
||||
let canEncrypt = true;
|
||||
|
||||
// If KeyUsage is present, check explicit bits
|
||||
if (ku) {
|
||||
canSign = ku.includes('digitalSignature') || ku.includes('contentCommitment');
|
||||
canEncrypt = ku.includes('keyEncipherment') || ku.includes('dataEncipherment') || ku.includes('keyAgreement');
|
||||
}
|
||||
|
||||
// If EKU is present, only reject if it explicitly excludes emailProtection
|
||||
if (eku && eku.length > 0) {
|
||||
const hasEmailProtection = eku.includes(OID_EMAIL_PROTECTION);
|
||||
// Only restrict if EKU is present and does NOT include emailProtection
|
||||
if (!hasEmailProtection) {
|
||||
canSign = false;
|
||||
canEncrypt = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { canSign, canEncrypt };
|
||||
}
|
||||
|
||||
/** Extract full metadata from a parsed certificate. */
|
||||
export async function extractCertificateInfo(
|
||||
cert: pkijs.Certificate,
|
||||
der: ArrayBuffer,
|
||||
): Promise<CertificateInfo> {
|
||||
const fingerprint = await computeFingerprint(der);
|
||||
const ku = extractKeyUsage(cert);
|
||||
const eku = extractExtendedKeyUsage(cert);
|
||||
const capabilities = classifyCapabilities(cert);
|
||||
|
||||
return {
|
||||
subject: rdnToString(cert.subject),
|
||||
issuer: rdnToString(cert.issuer),
|
||||
serialNumber: cert.serialNumber.valueBlock.valueHexView
|
||||
? Array.from(new Uint8Array(cert.serialNumber.valueBlock.valueHexView))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(':')
|
||||
: cert.serialNumber.valueBlock.toString(),
|
||||
notBefore: cert.notBefore.value.toISOString(),
|
||||
notAfter: cert.notAfter.value.toISOString(),
|
||||
fingerprint,
|
||||
algorithm: extractAlgorithm(cert),
|
||||
keyUsage: ku,
|
||||
extendedKeyUsage: eku,
|
||||
emailAddresses: extractEmailAddresses(cert),
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Crypto engine backed by webcrypto-liner for legacy algorithm support.
|
||||
*
|
||||
* webcrypto-liner extends the native Web Crypto API with algorithms
|
||||
* like DES-EDE3-CBC (3DES) that are commonly found in S/MIME messages
|
||||
* and PKCS#12 files produced by legacy clients (Outlook, Thunderbird, etc.).
|
||||
*
|
||||
* Native Web Crypto calls are passed through to the real implementation;
|
||||
* liner only intercepts algorithms that the browser doesn't natively support.
|
||||
*
|
||||
* Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only
|
||||
* handles PBES2 (OID 1.2.840.113549.1.5.13). Many PKCS#12 files use
|
||||
* legacy PBE algorithms (e.g. pbeWithSHAAnd3-KeyTripleDES-CBC). We
|
||||
* extend CryptoEngine to handle those via RFC 7292 Appendix B key
|
||||
* derivation + webcrypto-liner's DES-EDE3-CBC support.
|
||||
*/
|
||||
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
|
||||
// webcrypto-liner exports a Crypto constructor at runtime that extends native
|
||||
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
|
||||
// expose the type alias, so we import the module dynamically and cast.
|
||||
// Import the ES module build directly - the package's "browser" field points
|
||||
// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.).
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const liner = require('webcrypto-liner/build/index.es.js') as {
|
||||
Crypto: { new (): Crypto };
|
||||
setCrypto: (subtle: SubtleCrypto) => void;
|
||||
nativeCrypto: Crypto | Record<string, never>;
|
||||
};
|
||||
|
||||
// ── PKCS#12 legacy PBE OIDs ──────────────────────────────────────────
|
||||
const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3'; // pbeWithSHAAnd3-KeyTripleDES-CBC
|
||||
const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4'; // pbeWithSHAAnd2-KeyTripleDES-CBC
|
||||
const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5'; // pbeWithSHAAnd128BitRC2-CBC
|
||||
const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6'; // pbeWithSHAAnd40BitRC2-CBC
|
||||
|
||||
const LEGACY_PBE_OIDS = new Set([
|
||||
PBE_SHA1_3DES_3KEY,
|
||||
PBE_SHA1_3DES_2KEY,
|
||||
PBE_SHA1_RC2_128,
|
||||
PBE_SHA1_RC2_40,
|
||||
]);
|
||||
|
||||
/** Algorithm config for each legacy PBE OID. */
|
||||
function pbeConfig(oid: string): { keyLen: number; ivLen: number; algName: string } {
|
||||
switch (oid) {
|
||||
case PBE_SHA1_3DES_3KEY: return { keyLen: 24, ivLen: 8, algName: 'DES-EDE3-CBC' };
|
||||
case PBE_SHA1_3DES_2KEY: return { keyLen: 16, ivLen: 8, algName: 'DES-EDE3-CBC' };
|
||||
case PBE_SHA1_RC2_128: return { keyLen: 16, ivLen: 8, algName: 'RC2-CBC' };
|
||||
case PBE_SHA1_RC2_40: return { keyLen: 5, ivLen: 8, algName: 'RC2-CBC' };
|
||||
default: throw new Error(`Unsupported legacy PBE OID: ${oid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCS#12 key derivation - RFC 7292, Appendix B.
|
||||
*
|
||||
* @param password BMP-encoded password (with trailing 0x00 0x00)
|
||||
* @param salt raw salt bytes
|
||||
* @param iterations PBKDF iteration count
|
||||
* @param id 1 = key material, 2 = IV, 3 = MAC key
|
||||
* @param needed number of bytes to derive
|
||||
*/
|
||||
async function pkcs12KDF(
|
||||
password: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
iterations: number,
|
||||
id: number,
|
||||
needed: number,
|
||||
): Promise<Uint8Array> {
|
||||
const v = 64; // SHA-1 block size
|
||||
const u = 20; // SHA-1 output size
|
||||
|
||||
// Step 1: diversifier D = v bytes of 'id'
|
||||
const D = new Uint8Array(v);
|
||||
D.fill(id);
|
||||
|
||||
// Step 2: fill S from salt, padded/repeated to v-byte boundary
|
||||
const sLen = salt.length === 0 ? 0 : v * Math.ceil(salt.length / v);
|
||||
const S = new Uint8Array(sLen);
|
||||
for (let i = 0; i < sLen; i++) S[i] = salt[i % salt.length];
|
||||
|
||||
// Step 3: fill P from password, padded/repeated to v-byte boundary
|
||||
const pLen = password.length === 0 ? 0 : v * Math.ceil(password.length / v);
|
||||
const P = new Uint8Array(pLen);
|
||||
for (let i = 0; i < pLen; i++) P[i] = password[i % password.length];
|
||||
|
||||
// I = S || P
|
||||
const I = new Uint8Array(sLen + pLen);
|
||||
I.set(S, 0);
|
||||
I.set(P, sLen);
|
||||
|
||||
const c = Math.ceil(needed / u);
|
||||
const result = new Uint8Array(c * u);
|
||||
|
||||
for (let i = 0; i < c; i++) {
|
||||
// Aj = Hash^iterations(D || I)
|
||||
const buf = new Uint8Array(v + I.length);
|
||||
buf.set(D, 0);
|
||||
buf.set(I, v);
|
||||
|
||||
let A = new Uint8Array(await crypto.subtle.digest('SHA-1', buf));
|
||||
for (let j = 1; j < iterations; j++) {
|
||||
A = new Uint8Array(await crypto.subtle.digest('SHA-1', A));
|
||||
}
|
||||
|
||||
result.set(A, i * u);
|
||||
|
||||
if (i + 1 < c) {
|
||||
// Build B by repeating A to fill v bytes
|
||||
const B = new Uint8Array(v);
|
||||
for (let j = 0; j < v; j++) B[j] = A[j % u];
|
||||
|
||||
// I[j] = (I[j] + B + 1) mod 2^v for each v-byte block
|
||||
for (let j = 0; j < I.length; j += v) {
|
||||
let carry = 1;
|
||||
for (let k = v - 1; k >= 0; k--) {
|
||||
const sum = I[j + k] + B[k] + carry;
|
||||
I[j + k] = sum & 0xff;
|
||||
carry = sum >> 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, needed);
|
||||
}
|
||||
|
||||
/** Encode a password as BMP string with trailing NUL pair (RFC 7292 §B.1). */
|
||||
function passwordToBMP(password: ArrayBuffer): Uint8Array {
|
||||
const passView = new Uint8Array(password);
|
||||
// If already BMP-encoded (even length, every odd byte is 0x00 for ASCII),
|
||||
// or empty, use as-is. Otherwise convert char codes to big-endian UCS-2.
|
||||
// pkijs passes the password as a raw ArrayBuffer of char codes.
|
||||
const bmp = new Uint8Array(passView.length * 2 + 2);
|
||||
for (let i = 0; i < passView.length; i++) {
|
||||
bmp[i * 2] = 0;
|
||||
bmp[i * 2 + 1] = passView[i];
|
||||
}
|
||||
// trailing 0x00 0x00
|
||||
bmp[bmp.length - 2] = 0;
|
||||
bmp[bmp.length - 1] = 0;
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// ── CMS content encryption OIDs (for EnvelopedData decryption) ─────
|
||||
const OID_DES_EDE3_CBC = '1.2.840.113549.3.7'; // des-EDE3-CBC (3DES)
|
||||
const OID_DES_CBC = '1.3.14.3.2.7'; // desCBC
|
||||
const OID_RC2_CBC = '1.2.840.113549.3.2'; // rc2CBC
|
||||
|
||||
/**
|
||||
* Extended CryptoEngine that handles legacy algorithms (3DES, etc.)
|
||||
* not recognized by pkijs's default CryptoEngine.
|
||||
*
|
||||
* - Adds OID→algorithm mappings for DES-EDE3-CBC so that
|
||||
* EnvelopedData.decrypt() can process 3DES-encrypted S/MIME messages.
|
||||
* - Handles legacy PKCS#12 PBE algorithms via custom KDF.
|
||||
*/
|
||||
class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
|
||||
/**
|
||||
* Extend OID→algorithm mapping with legacy algorithms that webcrypto-liner
|
||||
* supports but pkijs does not know about.
|
||||
*/
|
||||
getAlgorithmByOID(oid: string, safety?: boolean, target?: string): object {
|
||||
switch (oid) {
|
||||
case OID_DES_EDE3_CBC:
|
||||
return { name: 'DES-EDE3-CBC', length: 192 };
|
||||
case OID_DES_CBC:
|
||||
return { name: 'DES-CBC', length: 64 };
|
||||
case OID_RC2_CBC:
|
||||
return { name: 'RC2-CBC', length: 128 };
|
||||
default:
|
||||
return super.getAlgorithmByOID(oid, safety, target);
|
||||
}
|
||||
}
|
||||
|
||||
getOIDByAlgorithm(algorithm: { name: string; length?: number }, safety?: boolean, target?: string): string {
|
||||
switch (algorithm.name.toUpperCase()) {
|
||||
case 'DES-EDE3-CBC':
|
||||
return OID_DES_EDE3_CBC;
|
||||
case 'DES-CBC':
|
||||
return OID_DES_CBC;
|
||||
case 'RC2-CBC':
|
||||
return OID_RC2_CBC;
|
||||
default:
|
||||
return super.getOIDByAlgorithm(algorithm, safety, target);
|
||||
}
|
||||
}
|
||||
|
||||
async decryptEncryptedContentInfo(
|
||||
parameters: Parameters<pkijs.CryptoEngine['decryptEncryptedContentInfo']>[0],
|
||||
): Promise<ArrayBuffer> {
|
||||
const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId;
|
||||
|
||||
if (!LEGACY_PBE_OIDS.has(oid)) {
|
||||
// Delegate to base CryptoEngine (handles PBES2)
|
||||
return super.decryptEncryptedContentInfo(parameters);
|
||||
}
|
||||
|
||||
const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams;
|
||||
if (!algParams) {
|
||||
throw new Error('Missing PBE algorithm parameters');
|
||||
}
|
||||
|
||||
// Parse PBEParameter ::= SEQUENCE { salt OCTET STRING, iterationCount INTEGER }
|
||||
const paramAsn1 = asn1js.fromBER(algParams.toBER(false));
|
||||
if (paramAsn1.offset === -1) {
|
||||
throw new Error('Invalid PBE parameters ASN.1');
|
||||
}
|
||||
const seq = paramAsn1.result as asn1js.Sequence;
|
||||
const salt = new Uint8Array((seq.valueBlock.value[0] as asn1js.OctetString).valueBlock.valueHexView);
|
||||
const iterations = (seq.valueBlock.value[1] as asn1js.Integer).valueBlock.valueDec;
|
||||
|
||||
const { keyLen, ivLen, algName } = pbeConfig(oid);
|
||||
const bmpPassword = passwordToBMP(parameters.password);
|
||||
|
||||
// Derive key (id=1) and IV (id=2) using PKCS#12 KDF
|
||||
const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen);
|
||||
const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen);
|
||||
|
||||
// Import key via webcrypto-liner (supports DES-EDE3-CBC)
|
||||
const keyData = new Uint8Array(keyBytes.buffer as ArrayBuffer, keyBytes.byteOffset, keyBytes.byteLength);
|
||||
const cryptoKey = await this.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: algName, length: keyLen * 8 } as Algorithm,
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
const ciphertext = parameters.encryptedContentInfo.getEncryptedContent();
|
||||
return this.decrypt(
|
||||
{ name: algName, iv: ivBytes } as Algorithm,
|
||||
cryptoKey,
|
||||
ciphertext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let linerEngine: Pkcs12CryptoEngine | null = null;
|
||||
let linerCryptoInstance: Crypto | null = null;
|
||||
|
||||
function ensureLiner() {
|
||||
if (!linerCryptoInstance) {
|
||||
// In Node.js, webcrypto-liner can't auto-detect the native crypto
|
||||
// (it looks for self.crypto which doesn't exist). Feed it manually
|
||||
// so that native algorithms (RSA, AES, etc.) stay hardware-accelerated
|
||||
// and only truly missing algorithms (3DES) use the software fallback.
|
||||
if (
|
||||
typeof liner.nativeCrypto?.getRandomValues !== 'function' &&
|
||||
typeof globalThis.crypto?.subtle !== 'undefined'
|
||||
) {
|
||||
liner.setCrypto(globalThis.crypto.subtle);
|
||||
}
|
||||
linerCryptoInstance = new liner.Crypto();
|
||||
}
|
||||
if (!linerEngine) {
|
||||
linerEngine = new Pkcs12CryptoEngine({
|
||||
crypto: linerCryptoInstance,
|
||||
subtle: linerCryptoInstance.subtle,
|
||||
name: 'webcrypto-liner',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a PKI.js CryptoEngine with 3DES (and other legacy algorithm) support. */
|
||||
export function getLinerCryptoEngine(): pkijs.CryptoEngine {
|
||||
ensureLiner();
|
||||
return linerEngine!;
|
||||
}
|
||||
|
||||
/** Get the webcrypto-liner Crypto instance (for importKey with legacy algorithms). */
|
||||
export function getLinerCrypto(): Crypto {
|
||||
ensureLiner();
|
||||
return linerCryptoInstance!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
|
||||
* then restore the previous engine afterwards.
|
||||
*
|
||||
* Required for operations that use the global engine internally
|
||||
* (e.g. PFX.parseInternalValues for PKCS#12 import).
|
||||
*/
|
||||
export async function withLinerEngine<T>(fn: () => Promise<T>): Promise<T> {
|
||||
ensureLiner();
|
||||
|
||||
// Save the current global engine so we can restore it
|
||||
const prev = pkijs.getEngine();
|
||||
|
||||
pkijs.setEngine('webcrypto-liner', linerCryptoInstance!, linerEngine!);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
// Restore the previous engine
|
||||
pkijs.setEngine(prev.name, prev.crypto as unknown as pkijs.CryptoEngine);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from './types';
|
||||
|
||||
const DB_NAME = 'smime-store';
|
||||
const DB_VERSION = 2;
|
||||
const KEY_RECORDS_STORE = 'key-records';
|
||||
const PUBLIC_CERTS_STORE = 'public-certs';
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
const oldVersion = event.oldVersion;
|
||||
if (oldVersion < 1) {
|
||||
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
||||
keyStore.createIndex('email', 'email', { unique: false });
|
||||
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
||||
certStore.createIndex('email', 'email', { unique: false });
|
||||
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
if (oldVersion >= 1 && oldVersion < 2) {
|
||||
// Add accountId index to existing stores
|
||||
const tx = request.transaction!;
|
||||
const keyStore = tx.objectStore(KEY_RECORDS_STORE);
|
||||
if (!keyStore.indexNames.contains('accountId')) {
|
||||
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
const certStore = tx.objectStore(PUBLIC_CERTS_STORE);
|
||||
if (!certStore.indexNames.contains('accountId')) {
|
||||
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function txPromise<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: globalThis.IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode);
|
||||
const store = tx.objectStore(storeName);
|
||||
const req = fn(store);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Key record CRUD ─────────────────────────────────────────────────
|
||||
|
||||
export async function saveKeyRecord(record: SmimeKeyRecord): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.put(record));
|
||||
}
|
||||
|
||||
export async function getKeyRecord(id: string): Promise<SmimeKeyRecord | undefined> {
|
||||
const db = await openDB();
|
||||
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.get(id));
|
||||
}
|
||||
|
||||
export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecord | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(KEY_RECORDS_STORE, 'readonly');
|
||||
const idx = tx.objectStore(KEY_RECORDS_STORE).index('email');
|
||||
const req = idx.get(email.toLowerCase());
|
||||
req.onsuccess = () => resolve(req.result ?? undefined);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listKeyRecords(accountId?: string): Promise<SmimeKeyRecord[]> {
|
||||
const db = await openDB();
|
||||
const all = await txPromise<SmimeKeyRecord[]>(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
||||
if (!accountId) return all;
|
||||
return all.filter((r) => r.accountId === accountId || !r.accountId);
|
||||
}
|
||||
|
||||
export async function deleteKeyRecord(id: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
// ── Public cert CRUD ────────────────────────────────────────────────
|
||||
|
||||
export async function savePublicCert(cert: SmimePublicCert): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.put(cert));
|
||||
}
|
||||
|
||||
export async function getPublicCertForEmail(email: string): Promise<SmimePublicCert | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(PUBLIC_CERTS_STORE, 'readonly');
|
||||
const idx = tx.objectStore(PUBLIC_CERTS_STORE).index('email');
|
||||
const req = idx.get(email.toLowerCase());
|
||||
req.onsuccess = () => resolve(req.result ?? undefined);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPublicCerts(accountId?: string): Promise<SmimePublicCert[]> {
|
||||
const db = await openDB();
|
||||
const all = await txPromise<SmimePublicCert[]>(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
||||
if (!accountId) return all;
|
||||
return all.filter((c) => c.accountId === accountId || !c.accountId);
|
||||
}
|
||||
|
||||
export async function deletePublicCert(id: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
/**
|
||||
* Minimal, deterministic MIME builder for outgoing S/MIME messages.
|
||||
*
|
||||
* Produces canonical text suitable for CMS signing/encryption.
|
||||
* All line endings are CRLF per RFC 5322.
|
||||
*/
|
||||
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
const CRLF = '\r\n';
|
||||
|
||||
export interface MimeAttachment {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
content: ArrayBuffer;
|
||||
cid?: string; // for inline images
|
||||
}
|
||||
|
||||
export interface MimeMessageInput {
|
||||
from: { name?: string; email: string };
|
||||
to: { name?: string; email: string }[];
|
||||
cc?: { name?: string; email: string }[];
|
||||
bcc?: { name?: string; email: string }[];
|
||||
subject: string;
|
||||
date?: Date;
|
||||
messageId?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
textBody?: string;
|
||||
htmlBody?: string;
|
||||
attachments?: MimeAttachment[];
|
||||
}
|
||||
|
||||
/** Build a complete MIME message and return it as a Uint8Array (UTF-8). */
|
||||
export function buildMimeMessage(input: MimeMessageInput): Uint8Array {
|
||||
const boundary = generateBoundary();
|
||||
const lines: string[] = [];
|
||||
|
||||
// Headers
|
||||
lines.push(formatHeader('From', formatAddress(input.from)));
|
||||
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
|
||||
if (input.cc?.length) {
|
||||
lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
|
||||
}
|
||||
// BCC is intentionally omitted from the MIME headers per RFC 5322
|
||||
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
||||
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
||||
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
|
||||
if (input.inReplyTo) {
|
||||
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
||||
}
|
||||
if (input.references?.length) {
|
||||
lines.push(formatHeader('References', input.references.join(' ')));
|
||||
}
|
||||
lines.push('MIME-Version: 1.0');
|
||||
|
||||
const hasText = !!input.textBody;
|
||||
const hasHtml = !!input.htmlBody;
|
||||
const hasAttachments = !!input.attachments?.length;
|
||||
|
||||
if (!hasAttachments && hasText && !hasHtml) {
|
||||
// text/plain only
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
} else if (!hasAttachments && hasText && hasHtml) {
|
||||
// multipart/alternative
|
||||
const altBoundary = generateBoundary();
|
||||
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
||||
lines.push('');
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
lines.push(`--${altBoundary}--`);
|
||||
} else if (!hasAttachments && !hasText && hasHtml) {
|
||||
// html only
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
} else if (hasAttachments) {
|
||||
// multipart/mixed
|
||||
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
||||
lines.push('');
|
||||
|
||||
// Body part
|
||||
if (hasText && hasHtml) {
|
||||
const altBoundary = generateBoundary();
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
||||
lines.push('');
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
lines.push(`--${altBoundary}`);
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
lines.push(`--${altBoundary}--`);
|
||||
} else if (hasText) {
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.textBody!));
|
||||
} else if (hasHtml) {
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push('Content-Type: text/html; charset=utf-8');
|
||||
lines.push('Content-Transfer-Encoding: quoted-printable');
|
||||
lines.push('');
|
||||
lines.push(quotedPrintableEncode(input.htmlBody!));
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for (const att of input.attachments!) {
|
||||
lines.push(`--${boundary}`);
|
||||
const disposition = att.cid ? 'inline' : 'attachment';
|
||||
lines.push(`Content-Type: ${att.contentType}; name="${encodeHeaderValue(att.filename)}"`);
|
||||
lines.push(`Content-Disposition: ${disposition}; filename="${encodeHeaderValue(att.filename)}"`);
|
||||
lines.push('Content-Transfer-Encoding: base64');
|
||||
if (att.cid) {
|
||||
lines.push(`Content-ID: <${att.cid}>`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(base64Encode(att.content));
|
||||
}
|
||||
lines.push(`--${boundary}--`);
|
||||
} else {
|
||||
// Empty body
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const raw = lines.join(CRLF);
|
||||
return new TextEncoder().encode(raw);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateBoundary(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return `----=_Part_${hex}`;
|
||||
}
|
||||
|
||||
function formatAddress(addr: { name?: string; email: string }): string {
|
||||
if (addr.name) {
|
||||
// RFC 5322 quoted-string for display name
|
||||
const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
return `"${escaped}" <${addr.email}>`;
|
||||
}
|
||||
return addr.email;
|
||||
}
|
||||
|
||||
function formatHeader(name: string, value: string): string {
|
||||
const full = `${name}: ${value}`;
|
||||
// RFC 5322 line length limit: fold at 76 chars
|
||||
if (full.length <= 76) return full;
|
||||
const parts: string[] = [];
|
||||
let remaining = full;
|
||||
let first = true;
|
||||
while (remaining.length > 76) {
|
||||
let breakAt = 76;
|
||||
// Find a space to break at
|
||||
const spaceIdx = remaining.lastIndexOf(' ', 76);
|
||||
if (spaceIdx > (first ? name.length + 2 : 1)) {
|
||||
breakAt = spaceIdx;
|
||||
}
|
||||
parts.push(remaining.slice(0, breakAt));
|
||||
remaining = ' ' + remaining.slice(breakAt).trimStart();
|
||||
first = false;
|
||||
}
|
||||
parts.push(remaining);
|
||||
return parts.join(CRLF);
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
// Use RFC 2047 encoded-word if non-ASCII
|
||||
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
||||
const encoded = Array.from(new TextEncoder().encode(value))
|
||||
.map((b) => {
|
||||
if (
|
||||
(b >= 0x30 && b <= 0x39) || // 0-9
|
||||
(b >= 0x41 && b <= 0x5a) || // A-Z
|
||||
(b >= 0x61 && b <= 0x7a) // a-z
|
||||
) {
|
||||
return String.fromCharCode(b);
|
||||
}
|
||||
return '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
||||
})
|
||||
.join('');
|
||||
return `=?UTF-8?Q?${encoded}?=`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
// RFC 5322 date format
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const d = days[date.getUTCDay()];
|
||||
const dd = date.getUTCDate();
|
||||
const m = months[date.getUTCMonth()];
|
||||
const y = date.getUTCFullYear();
|
||||
const hh = date.getUTCHours().toString().padStart(2, '0');
|
||||
const mm = date.getUTCMinutes().toString().padStart(2, '0');
|
||||
const ss = date.getUTCSeconds().toString().padStart(2, '0');
|
||||
return `${d}, ${dd} ${m} ${y} ${hh}:${mm}:${ss} +0000`;
|
||||
}
|
||||
|
||||
export interface SmimeWrapInput {
|
||||
from: { name?: string; email: string };
|
||||
to: { name?: string; email: string }[];
|
||||
cc?: { name?: string; email: string }[];
|
||||
subject: string;
|
||||
date?: Date;
|
||||
messageId?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
smimeType: 'signed-data' | 'enveloped-data';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a CMS binary blob in a proper RFC 5322 / S/MIME message.
|
||||
*
|
||||
* The server needs RFC 5322 headers (From, To, Subject, etc.) to route
|
||||
* the message; the CMS blob becomes the base64-encoded body.
|
||||
*/
|
||||
export function wrapCmsAsSmimeMessage(cmsBlob: Blob | ArrayBuffer | Uint8Array, input: SmimeWrapInput): Blob {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(formatHeader('From', formatAddress(input.from)));
|
||||
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
|
||||
if (input.cc?.length) {
|
||||
lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
|
||||
}
|
||||
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
||||
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
||||
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
|
||||
if (input.inReplyTo) {
|
||||
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
||||
}
|
||||
if (input.references?.length) {
|
||||
lines.push(formatHeader('References', input.references.join(' ')));
|
||||
}
|
||||
lines.push('MIME-Version: 1.0');
|
||||
lines.push(`Content-Type: application/pkcs7-mime; smime-type=${input.smimeType}; name="smime.p7m"`);
|
||||
lines.push('Content-Transfer-Encoding: base64');
|
||||
lines.push('Content-Disposition: attachment; filename="smime.p7m"');
|
||||
lines.push('');
|
||||
|
||||
const headerPart = lines.join(CRLF);
|
||||
|
||||
// We'll combine header bytes + base64 body
|
||||
const headerBytes = new TextEncoder().encode(headerPart);
|
||||
|
||||
return new Blob([headerBytes, cmsToBase64Blob(cmsBlob)], { type: 'message/rfc822' });
|
||||
}
|
||||
|
||||
function cmsToBase64Blob(data: Blob | ArrayBuffer | Uint8Array): Blob {
|
||||
let bytes: Uint8Array;
|
||||
if (data instanceof Uint8Array) {
|
||||
bytes = data;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(data);
|
||||
} else {
|
||||
// Blob - we need sync; caller should have converted. Fallback to empty.
|
||||
bytes = new Uint8Array(0);
|
||||
}
|
||||
const b64 = base64Encode(bytes.buffer as ArrayBuffer);
|
||||
return new Blob([new TextEncoder().encode(b64 + CRLF)]);
|
||||
}
|
||||
|
||||
/** Encode string as quoted-printable (RFC 2045). */
|
||||
export function quotedPrintableEncode(input: string): string {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
const lines: string[] = [];
|
||||
let line = '';
|
||||
|
||||
for (const b of bytes) {
|
||||
let encoded: string;
|
||||
if (b === 0x0d || b === 0x0a) {
|
||||
// Pass through CRLF as-is (handled below)
|
||||
encoded = String.fromCharCode(b);
|
||||
} else if (
|
||||
b === 0x09 || // tab
|
||||
(b >= 0x20 && b <= 0x7e && b !== 0x3d) // printable, not '='
|
||||
) {
|
||||
encoded = String.fromCharCode(b);
|
||||
} else {
|
||||
encoded = '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
||||
}
|
||||
|
||||
if (b === 0x0a) {
|
||||
// End current line (strip any trailing \r already added)
|
||||
if (line.endsWith('\r')) {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
lines.push(line);
|
||||
line = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length + encoded.length > 75) {
|
||||
lines.push(line + '=');
|
||||
line = encoded;
|
||||
} else {
|
||||
line += encoded;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
return lines.join(CRLF);
|
||||
}
|
||||
|
||||
/** Encode ArrayBuffer as base64 with line breaks at 76 chars. */
|
||||
export function base64Encode(data: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(data);
|
||||
let binary = '';
|
||||
for (const b of bytes) {
|
||||
binary += String.fromCharCode(b);
|
||||
}
|
||||
const b64 = btoa(binary);
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < b64.length; i += 76) {
|
||||
lines.push(b64.slice(i, i + 76));
|
||||
}
|
||||
return lines.join(CRLF);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { decryptPrivateKeyBytes } from './pkcs12-import';
|
||||
import type { SmimeKeyRecord } from './types';
|
||||
|
||||
function stringToArrayBuffer(str: string): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an S/MIME key record as a PKCS#12 (.p12) file.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Decrypt the stored PKCS#8 private key bytes using the storage passphrase.
|
||||
* 2. Build a PKCS#12 container with the private key, leaf cert, and chain.
|
||||
* 3. Protect the PKCS#12 with the export passphrase.
|
||||
* 4. Return the resulting bytes for browser download.
|
||||
*/
|
||||
export async function exportPkcs12(
|
||||
record: SmimeKeyRecord,
|
||||
storagePassphrase: string,
|
||||
exportPassphrase: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
// Step 1: Decrypt the stored private key
|
||||
const pkcs8Bytes = await decryptPrivateKeyBytes(record, storagePassphrase);
|
||||
|
||||
// Step 2: Parse the leaf certificate
|
||||
const leafCertAsn1 = asn1js.fromBER(record.certificate);
|
||||
if (leafCertAsn1.offset === -1) {
|
||||
throw new Error('Failed to parse leaf certificate');
|
||||
}
|
||||
const leafCert = new pkijs.Certificate({ schema: leafCertAsn1.result });
|
||||
|
||||
// Parse chain certificates
|
||||
const chainCerts = record.certificateChain.map((chainDer) => {
|
||||
const chainAsn1 = asn1js.fromBER(chainDer);
|
||||
if (chainAsn1.offset === -1) {
|
||||
throw new Error('Failed to parse chain certificate');
|
||||
}
|
||||
return new pkijs.Certificate({ schema: chainAsn1.result });
|
||||
});
|
||||
|
||||
const passwordBuf = stringToArrayBuffer(exportPassphrase);
|
||||
|
||||
// Step 3: Build the PKCS#12 structure
|
||||
// Create key bag
|
||||
const keyBag = new pkijs.PKCS8ShroudedKeyBag({
|
||||
parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes),
|
||||
});
|
||||
|
||||
await keyBag.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
contentEncryptionAlgorithm: {
|
||||
name: 'AES-CBC',
|
||||
length: 256,
|
||||
} as unknown as Parameters<typeof keyBag.makeInternalValues>[0]['contentEncryptionAlgorithm'],
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
iterationCount: 100_000,
|
||||
});
|
||||
|
||||
const keyBagSafe = new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.2', // pkcs8ShroudedKeyBag
|
||||
bagValue: keyBag,
|
||||
bagAttributes: [
|
||||
new pkijs.Attribute({
|
||||
type: '1.2.840.113549.1.9.20', // friendlyName
|
||||
values: [new asn1js.BmpString({ value: record.email })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Create cert bags
|
||||
const certBags = [
|
||||
new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.3', // certBag
|
||||
bagValue: new pkijs.CertBag({
|
||||
parsedValue: leafCert,
|
||||
}),
|
||||
bagAttributes: [
|
||||
new pkijs.Attribute({
|
||||
type: '1.2.840.113549.1.9.20',
|
||||
values: [new asn1js.BmpString({ value: record.email })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...chainCerts.map(
|
||||
(cert) =>
|
||||
new pkijs.SafeBag({
|
||||
bagId: '1.2.840.113549.1.12.10.1.3',
|
||||
bagValue: new pkijs.CertBag({
|
||||
parsedValue: cert,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
// Build authenticated safe with two SafeContents:
|
||||
// 1. Key bag (password-encrypted)
|
||||
// 2. Cert bags (unencrypted)
|
||||
const authenticatedSafe = new pkijs.AuthenticatedSafe({
|
||||
parsedValue: {
|
||||
safeContents: [
|
||||
{
|
||||
privacyMode: 0, // no extra encryption - key bag is already shrouded
|
||||
value: new pkijs.SafeContents({
|
||||
safeBags: [keyBagSafe],
|
||||
}),
|
||||
},
|
||||
{
|
||||
privacyMode: 0,
|
||||
value: new pkijs.SafeContents({
|
||||
safeBags: certBags,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await authenticatedSafe.makeInternalValues({
|
||||
safeContents: [{}, {}],
|
||||
});
|
||||
|
||||
const pfx = new pkijs.PFX({
|
||||
parsedValue: {
|
||||
integrityMode: 0,
|
||||
authenticatedSafe,
|
||||
},
|
||||
});
|
||||
|
||||
await pfx.makeInternalValues({
|
||||
password: passwordBuf,
|
||||
iterations: 100_000,
|
||||
pbkdf2HashAlgorithm: 'SHA-256',
|
||||
hmacHashAlgorithm: 'SHA-256',
|
||||
});
|
||||
|
||||
// Step 4: Serialize to DER
|
||||
return pfx.toSchema().toBER(false);
|
||||
}
|
||||
|
||||
/** Trigger a browser download of the PKCS#12 file. */
|
||||
export function downloadPkcs12(p12Bytes: ArrayBuffer, filename: string): void {
|
||||
const blob = new Blob([p12Bytes], { type: 'application/x-pkcs12' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import {
|
||||
extractCertificateInfo,
|
||||
classifyCapabilities,
|
||||
} from './certificate-utils';
|
||||
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
||||
import { withLinerEngine, getLinerCrypto } from './crypto-engine';
|
||||
|
||||
const KDF_ITERATIONS = 600_000;
|
||||
const AES_KEY_LENGTH = 256;
|
||||
|
||||
function stringToAB(str: string): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Parse a PKCS#12 (.p12/.pfx) file and produce an encrypted-at-rest key record. */
|
||||
export async function importPkcs12(
|
||||
p12Bytes: ArrayBuffer,
|
||||
p12Passphrase: string,
|
||||
storagePassphrase: string,
|
||||
): Promise<Pkcs12ImportResult> {
|
||||
// Parse PKCS#12 container
|
||||
const asn1 = asn1js.fromBER(p12Bytes);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid PKCS#12 file: ASN.1 parsing failed');
|
||||
}
|
||||
|
||||
const pfx = new pkijs.PFX({ schema: asn1.result });
|
||||
|
||||
// Verify MAC if present
|
||||
if (pfx.macData) {
|
||||
// PKIjs handles MAC verification internally during parseInternalValues
|
||||
}
|
||||
|
||||
// Use webcrypto-liner as the global engine for 3DES support.
|
||||
// Many PKCS#12 files use pbeWithSHAAnd3-KeyTripleDES-CBC internally.
|
||||
await withLinerEngine(async () => {
|
||||
await pfx.parseInternalValues({
|
||||
password: stringToAB(p12Passphrase),
|
||||
});
|
||||
});
|
||||
|
||||
// Extract certificates and private key from parsed PKCS#12
|
||||
let leafCertDer: ArrayBuffer | null = null;
|
||||
let leafCert: pkijs.Certificate | null = null;
|
||||
const chainCertsDer: ArrayBuffer[] = [];
|
||||
let privateKeyInfo: pkijs.PrivateKeyInfo | null = null;
|
||||
|
||||
if (!pfx.parsedValue?.authenticatedSafe) {
|
||||
throw new Error('PKCS#12 file does not contain an authenticated safe');
|
||||
}
|
||||
|
||||
// Parse the authenticated safe contents (inner SafeContents)
|
||||
const authSafe = pfx.parsedValue.authenticatedSafe;
|
||||
const safeContentsParams = authSafe.safeContents.map((ci: pkijs.ContentInfo) => {
|
||||
// encryptedData (1.2.840.113549.1.7.6) needs the password
|
||||
if (ci.contentType === '1.2.840.113549.1.7.6') {
|
||||
return { password: stringToAB(p12Passphrase) };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await withLinerEngine(async () => {
|
||||
await authSafe.parseInternalValues({ safeContents: safeContentsParams });
|
||||
});
|
||||
|
||||
for (const safeContent of authSafe.parsedValue.safeContents) {
|
||||
const sc = safeContent.value ?? safeContent.parsedValue;
|
||||
if (!sc) continue;
|
||||
|
||||
for (const safeBag of sc.safeBags) {
|
||||
// PKCS#12 bag types
|
||||
switch (safeBag.bagId) {
|
||||
case '1.2.840.113549.1.12.10.1.3': {
|
||||
// CertBag
|
||||
const certBag = safeBag.bagValue as pkijs.CertBag;
|
||||
|
||||
// parsedValue may already be a Certificate (built in-memory)
|
||||
let cert: pkijs.Certificate | null = null;
|
||||
let der: ArrayBuffer | null = null;
|
||||
|
||||
if (certBag.parsedValue instanceof pkijs.Certificate) {
|
||||
cert = certBag.parsedValue;
|
||||
der = cert.toSchema(true).toBER(false);
|
||||
} else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) {
|
||||
// x509Certificate - extract DER from the OCTET STRING
|
||||
const certDerBytes = (certBag.certValue as asn1js.OctetString).valueBlock.valueHexView;
|
||||
const certAsn1 = asn1js.fromBER(certDerBytes);
|
||||
if (certAsn1.offset !== -1) {
|
||||
cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
der = new Uint8Array(certDerBytes).buffer as ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
if (cert && der) {
|
||||
if (!leafCertDer) {
|
||||
leafCertDer = der;
|
||||
leafCert = cert;
|
||||
} else {
|
||||
chainCertsDer.push(der);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '1.2.840.113549.1.12.10.1.1': {
|
||||
// KeyBag (unencrypted private key)
|
||||
privateKeyInfo = safeBag.bagValue as pkijs.PrivateKeyInfo;
|
||||
break;
|
||||
}
|
||||
case '1.2.840.113549.1.12.10.1.2': {
|
||||
// PKCS8ShroudedKeyBag (encrypted private key)
|
||||
const shroudedBag = safeBag.bagValue as pkijs.PKCS8ShroudedKeyBag;
|
||||
if (shroudedBag.parsedValue) {
|
||||
privateKeyInfo = shroudedBag.parsedValue;
|
||||
} else {
|
||||
// Decrypt shrouded key bag to get private key info
|
||||
await withLinerEngine(async () => {
|
||||
await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise<void> }).parseInternalValues({
|
||||
password: stringToAB(p12Passphrase),
|
||||
});
|
||||
});
|
||||
if (shroudedBag.parsedValue) {
|
||||
privateKeyInfo = shroudedBag.parsedValue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!leafCert || !leafCertDer) {
|
||||
throw new Error('No certificate found in PKCS#12 file');
|
||||
}
|
||||
if (!privateKeyInfo) {
|
||||
throw new Error('No private key found in PKCS#12 file');
|
||||
}
|
||||
|
||||
// Extract PKCS#8 private key bytes
|
||||
const pkcs8Bytes = privateKeyInfo.toSchema().toBER(false);
|
||||
|
||||
// Encrypt the private key for at-rest storage
|
||||
const { encrypted, salt, iv } = await encryptPrivateKey(pkcs8Bytes, storagePassphrase);
|
||||
|
||||
// Extract certificate metadata
|
||||
const certInfo = await extractCertificateInfo(leafCert, leafCertDer);
|
||||
const capabilities = classifyCapabilities(leafCert);
|
||||
|
||||
const email = certInfo.emailAddresses[0] ?? '';
|
||||
|
||||
const keyRecord: SmimeKeyRecord = {
|
||||
id: generateUUID(),
|
||||
email: email.toLowerCase(),
|
||||
certificate: leafCertDer,
|
||||
certificateChain: chainCertsDer,
|
||||
encryptedPrivateKey: encrypted,
|
||||
salt,
|
||||
iv,
|
||||
kdfIterations: KDF_ITERATIONS,
|
||||
issuer: certInfo.issuer,
|
||||
subject: certInfo.subject,
|
||||
serialNumber: certInfo.serialNumber,
|
||||
notBefore: certInfo.notBefore,
|
||||
notAfter: certInfo.notAfter,
|
||||
fingerprint: certInfo.fingerprint,
|
||||
algorithm: certInfo.algorithm,
|
||||
capabilities,
|
||||
};
|
||||
|
||||
return { keyRecord, certInfo };
|
||||
}
|
||||
|
||||
// ── Private key encryption / decryption ──────────────────────────────
|
||||
|
||||
async function deriveWrappingKey(
|
||||
passphrase: string,
|
||||
salt: ArrayBuffer,
|
||||
iterations: number,
|
||||
): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(passphrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: AES_KEY_LENGTH },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptPrivateKey(
|
||||
pkcs8Bytes: ArrayBuffer,
|
||||
passphrase: string,
|
||||
): Promise<{ encrypted: ArrayBuffer; salt: ArrayBuffer; iv: ArrayBuffer }> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32)).buffer;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12)).buffer;
|
||||
const wrappingKey = await deriveWrappingKey(passphrase, salt, KDF_ITERATIONS);
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
wrappingKey,
|
||||
pkcs8Bytes,
|
||||
);
|
||||
return { encrypted, salt, iv };
|
||||
}
|
||||
|
||||
export interface UnlockedKeyPair {
|
||||
signingKey: CryptoKey;
|
||||
decryptionKey?: CryptoKey;
|
||||
/** Key imported via webcrypto-liner as RSAES-PKCS1-v1_5 for legacy S/MIME (3DES) messages */
|
||||
legacyDecryptionKey?: CryptoKey;
|
||||
}
|
||||
|
||||
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
|
||||
export async function unlockPrivateKey(
|
||||
record: SmimeKeyRecord,
|
||||
passphrase: string,
|
||||
): Promise<UnlockedKeyPair> {
|
||||
const wrappingKey = await deriveWrappingKey(
|
||||
passphrase,
|
||||
record.salt,
|
||||
record.kdfIterations,
|
||||
);
|
||||
|
||||
let pkcs8Bytes: ArrayBuffer;
|
||||
try {
|
||||
pkcs8Bytes = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: record.iv },
|
||||
wrappingKey,
|
||||
record.encryptedPrivateKey,
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Incorrect passphrase');
|
||||
}
|
||||
|
||||
const isEcdsa = record.algorithm.startsWith('ECDSA');
|
||||
const signAlg = isEcdsa
|
||||
? { name: 'ECDSA', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
|
||||
: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
||||
const decryptAlg = isEcdsa
|
||||
? { name: 'ECDH', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
|
||||
: { name: 'RSA-OAEP', hash: 'SHA-256' };
|
||||
const decryptUsages: globalThis.KeyUsage[] = isEcdsa ? ['deriveBits'] : ['decrypt'];
|
||||
|
||||
// Import for signing
|
||||
let signingKey: CryptoKey;
|
||||
try {
|
||||
signingKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, signAlg, false, ['sign']);
|
||||
} catch {
|
||||
// Key may only support decryption (key-encipherment-only cert)
|
||||
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
||||
let legacyDecryptionKey: CryptoKey | undefined;
|
||||
if (!isEcdsa) {
|
||||
try {
|
||||
const linerCrypto = getLinerCrypto();
|
||||
legacyDecryptionKey = await linerCrypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8Bytes,
|
||||
{ name: 'RSAES-PKCS1-v1_5' },
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
} catch {
|
||||
// webcrypto-liner may not be available
|
||||
}
|
||||
}
|
||||
return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
|
||||
}
|
||||
|
||||
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
|
||||
let decryptionKey: CryptoKey | undefined;
|
||||
try {
|
||||
decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
||||
} catch {
|
||||
// Key may only support signing (digitalSignature-only cert)
|
||||
}
|
||||
|
||||
// Import a legacy decryption key via webcrypto-liner for RSAES-PKCS1-v1_5 key transport
|
||||
// (used by older S/MIME messages encrypted with 3DES, RC2, etc.)
|
||||
let legacyDecryptionKey: CryptoKey | undefined;
|
||||
if (!isEcdsa) {
|
||||
try {
|
||||
const linerCrypto = getLinerCrypto();
|
||||
legacyDecryptionKey = await linerCrypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8Bytes,
|
||||
{ name: 'RSAES-PKCS1-v1_5' },
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
console.debug('[S/MIME] legacy RSAES-PKCS1-v1_5 key imported successfully:', {
|
||||
algorithm: legacyDecryptionKey.algorithm,
|
||||
usages: legacyDecryptionKey.usages,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[S/MIME] legacy RSAES-PKCS1-v1_5 key import failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { signingKey, decryptionKey, legacyDecryptionKey };
|
||||
}
|
||||
|
||||
/** Get decrypted PKCS#8 bytes (for export flow). */
|
||||
export async function decryptPrivateKeyBytes(
|
||||
record: SmimeKeyRecord,
|
||||
passphrase: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const wrappingKey = await deriveWrappingKey(
|
||||
passphrase,
|
||||
record.salt,
|
||||
record.kdfIterations,
|
||||
);
|
||||
|
||||
try {
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: record.iv },
|
||||
wrappingKey,
|
||||
record.encryptedPrivateKey,
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Incorrect passphrase');
|
||||
}
|
||||
}
|
||||
|
||||
function ecdsaCurveFromAlg(alg: string): string {
|
||||
if (alg.includes('P256') || alg.includes('P-256')) return 'P-256';
|
||||
if (alg.includes('P384') || alg.includes('P-384')) return 'P-384';
|
||||
if (alg.includes('P521') || alg.includes('P-521')) return 'P-521';
|
||||
return 'P-256';
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
/**
|
||||
* Decrypt CMS EnvelopedData to recover the inner MIME content.
|
||||
*
|
||||
* Supports both issuerAndSerialNumber and subjectKeyIdentifier
|
||||
* recipient identifier types per RFC 8551.
|
||||
*/
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import type { SmimeKeyRecord } from './types';
|
||||
import { getLinerCryptoEngine, withLinerEngine } from './crypto-engine';
|
||||
|
||||
export interface DecryptionInput {
|
||||
/** Raw CMS EnvelopedData bytes (DER) */
|
||||
cmsBytes: ArrayBuffer;
|
||||
/** All imported key records to try matching against */
|
||||
keyRecords: SmimeKeyRecord[];
|
||||
/** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */
|
||||
unlockedKeys: Map<string, CryptoKey>;
|
||||
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
|
||||
legacyUnlockedKeys?: Map<string, CryptoKey>;
|
||||
}
|
||||
|
||||
export interface DecryptionResult {
|
||||
/** The decrypted inner MIME bytes */
|
||||
mimeBytes: Uint8Array;
|
||||
/** The key record that was used to decrypt */
|
||||
keyRecordId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to decrypt CMS EnvelopedData.
|
||||
*
|
||||
* Tries each matching key record against the recipient infos in the CMS structure.
|
||||
*
|
||||
* @throws Error if no matching key is found, key is locked, or decryption fails
|
||||
*/
|
||||
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
||||
const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
|
||||
|
||||
// Parse the CMS ContentInfo wrapper
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const envelopedData = extractEnvelopedData(contentInfo);
|
||||
|
||||
// Log CMS algorithm details for diagnostics
|
||||
const contentEncOid = envelopedData.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId;
|
||||
const recipientAlgs = envelopedData.recipientInfos?.map((ri) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ri as any).value?.keyEncryptionAlgorithm?.algorithmId as string | undefined,
|
||||
);
|
||||
console.debug('[S/MIME] CMS algorithms:', {
|
||||
contentEncryption: contentEncOid,
|
||||
keyTransport: recipientAlgs,
|
||||
legacyKeysAvailable: legacyUnlockedKeys?.size ?? 0,
|
||||
});
|
||||
|
||||
// Find matching key records
|
||||
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
|
||||
|
||||
if (matchedRecords.length === 0) {
|
||||
throw new Error('No imported S/MIME key matches any recipient in this encrypted message');
|
||||
}
|
||||
|
||||
// Try each matched record
|
||||
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
||||
const privateKey = unlockedKeys.get(keyRecord.id);
|
||||
if (!privateKey) {
|
||||
// Try legacy key (RSAES-PKCS1-v1_5) if no RSA-OAEP key
|
||||
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
|
||||
if (legacyKey) {
|
||||
try {
|
||||
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
|
||||
return {
|
||||
mimeBytes: new Uint8Array(decrypted),
|
||||
keyRecordId: keyRecord.id,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
continue; // Key exists but isn't unlocked - skip, caller should unlock first
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord);
|
||||
return {
|
||||
mimeBytes: new Uint8Array(decrypted),
|
||||
keyRecordId: keyRecord.id,
|
||||
};
|
||||
} catch (oaepError) {
|
||||
// RSA-OAEP key didn't work, try legacy RSAES-PKCS1-v1_5 key
|
||||
console.debug('[S/MIME] RSA-OAEP decrypt failed:', oaepError instanceof Error ? oaepError.message : oaepError);
|
||||
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
|
||||
console.debug('[S/MIME] legacy key available:', !!legacyKey, legacyKey ? { algorithm: (legacyKey as CryptoKey).algorithm } : undefined);
|
||||
if (legacyKey) {
|
||||
try {
|
||||
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
|
||||
return {
|
||||
mimeBytes: new Uint8Array(decrypted),
|
||||
keyRecordId: keyRecord.id,
|
||||
};
|
||||
} catch (legacyError) {
|
||||
// Legacy key also didn't work, try the next record
|
||||
console.debug('[S/MIME] RSAES-PKCS1-v1_5 decrypt also failed:', legacyError instanceof Error ? legacyError.message : legacyError);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we had matching records but none were unlocked
|
||||
const isUnlocked = (id: string) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false);
|
||||
const hasLockedMatch = matchedRecords.some(m => !isUnlocked(m.keyRecord.id));
|
||||
if (hasLockedMatch) {
|
||||
const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!;
|
||||
throw new SmimeKeyLockedError(
|
||||
'S/MIME key is locked. Unlock it to decrypt this message.',
|
||||
lockedRecord.keyRecord.id,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Failed to decrypt message with any available key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key record IDs that could potentially decrypt a message.
|
||||
* Useful for prompting the user to unlock the right key.
|
||||
*/
|
||||
export function findDecryptionCandidates(
|
||||
cmsBytes: ArrayBuffer,
|
||||
keyRecords: SmimeKeyRecord[],
|
||||
): string[] {
|
||||
try {
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const envelopedData = extractEnvelopedData(contentInfo);
|
||||
const matches = findMatchingKeyRecords(envelopedData, keyRecords);
|
||||
return matches.map(m => m.keyRecord.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class SmimeKeyLockedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly keyRecordId: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SmimeKeyLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
/**
|
||||
* Normalize raw blob bytes into DER-encoded CMS data.
|
||||
*
|
||||
* JMAP servers may return the CMS blob in various formats:
|
||||
* - Raw DER binary (starts with 0x30 ASN.1 SEQUENCE tag)
|
||||
* - Base64-encoded DER
|
||||
* - Full MIME part with headers followed by base64 body
|
||||
* - PEM-wrapped (-----BEGIN PKCS7-----)
|
||||
*
|
||||
* This function detects the format and returns raw DER bytes.
|
||||
*/
|
||||
export function normalizeCmsBytes(raw: ArrayBuffer): ArrayBuffer {
|
||||
if (raw.byteLength === 0) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(raw);
|
||||
|
||||
// Already valid DER - starts with ASN.1 SEQUENCE tag
|
||||
if (bytes[0] === 0x30) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
let text = new TextDecoder().decode(raw);
|
||||
|
||||
const looksMostlyText = (() => {
|
||||
const sample = text.slice(0, Math.min(text.length, 2048));
|
||||
if (sample.length === 0) return false;
|
||||
let printable = 0;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const code = sample.charCodeAt(i);
|
||||
if (
|
||||
code === 0x09 ||
|
||||
code === 0x0a ||
|
||||
code === 0x0d ||
|
||||
(code >= 0x20 && code <= 0x7e)
|
||||
) {
|
||||
printable++;
|
||||
}
|
||||
}
|
||||
return printable / sample.length > 0.85;
|
||||
})();
|
||||
|
||||
// Check if the blob contains MIME headers (e.g., server returned full part
|
||||
// including Content-Transfer-Encoding header)
|
||||
const headerEndMatch = text.match(/\r?\n\r?\n/);
|
||||
const hasMimeHeaderHints = /content-type:|content-transfer-encoding:|mime-version:/i.test(text.slice(0, Math.min(text.length, 8192)));
|
||||
if (looksMostlyText && headerEndMatch && headerEndMatch.index !== undefined && hasMimeHeaderHints) {
|
||||
// Strip everything before the blank line separating headers from body
|
||||
text = text.substring(headerEndMatch.index + headerEndMatch[0].length);
|
||||
}
|
||||
|
||||
// Strip PEM armour if present
|
||||
text = text
|
||||
.replace(/-----BEGIN [A-Z0-9 ]+-----/g, '')
|
||||
.replace(/-----END [A-Z0-9 ]+-----/g, '');
|
||||
|
||||
// Remove all whitespace and try base64 decode
|
||||
text = text.replace(/\s/g, '');
|
||||
|
||||
if (text.length === 0) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
try {
|
||||
const binary = atob(text);
|
||||
const decoded = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
|
||||
if (decoded.length > 0 && decoded[0] === 0x30) {
|
||||
return decoded.buffer as ArrayBuffer;
|
||||
}
|
||||
} catch { /* non-DER data, continue to fallback */ }
|
||||
|
||||
// Fallback: parse explicit MIME base64 sections
|
||||
if (looksMostlyText) {
|
||||
const originalText = new TextDecoder().decode(raw);
|
||||
const sectionRegex = /content-transfer-encoding:\s*base64[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--[^\r\n]+|$)/ig;
|
||||
const sectionBlocks: string[] = [];
|
||||
let sectionMatch: RegExpExecArray | null = null;
|
||||
while ((sectionMatch = sectionRegex.exec(originalText)) !== null) {
|
||||
sectionBlocks.push(sectionMatch[1]);
|
||||
}
|
||||
|
||||
for (const block of sectionBlocks) {
|
||||
const cleaned = block.replace(/\s/g, '');
|
||||
if (cleaned.length < 8 || !/^[A-Za-z0-9+/=]+$/.test(cleaned)) continue;
|
||||
try {
|
||||
const binary = atob(cleaned);
|
||||
const decoded = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
|
||||
if (decoded.length > 0 && decoded[0] === 0x30) {
|
||||
return decoded.buffer as ArrayBuffer;
|
||||
}
|
||||
} catch {
|
||||
// try next section
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: find base64-like blocks and keep only DER-looking decodes
|
||||
const base64Blocks = originalText.match(/[A-Za-z0-9+/=\r\n]{128,}/g) || [];
|
||||
const cleaned = base64Blocks
|
||||
.map(block => block.replace(/\s/g, ''))
|
||||
.filter(block => block.length >= 128 && /^[A-Za-z0-9+/=]+$/.test(block));
|
||||
|
||||
cleaned.sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const block of cleaned) {
|
||||
try {
|
||||
const binary = atob(block);
|
||||
const decoded = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
|
||||
if (decoded.length > 0 && decoded[0] === 0x30) {
|
||||
return decoded.buffer as ArrayBuffer;
|
||||
}
|
||||
} catch {
|
||||
// try next block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not decodable - return original bytes
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
||||
const asn1 = asn1js.fromBER(der);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid ASN.1 data - cannot parse CMS envelope');
|
||||
}
|
||||
try {
|
||||
return new pkijs.ContentInfo({ schema: asn1.result });
|
||||
} catch {
|
||||
throw new Error('Invalid ASN.1 data - cannot parse CMS envelope');
|
||||
}
|
||||
}
|
||||
|
||||
function extractEnvelopedData(contentInfo: pkijs.ContentInfo): pkijs.EnvelopedData {
|
||||
// OID 1.2.840.113549.1.7.3 = enveloped-data
|
||||
if (contentInfo.contentType !== '1.2.840.113549.1.7.3') {
|
||||
throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`);
|
||||
}
|
||||
return new pkijs.EnvelopedData({ schema: contentInfo.content });
|
||||
}
|
||||
|
||||
interface RecipientMatch {
|
||||
keyRecord: SmimeKeyRecord;
|
||||
recipientIndex: number;
|
||||
}
|
||||
|
||||
function findMatchingKeyRecords(
|
||||
envelopedData: pkijs.EnvelopedData,
|
||||
keyRecords: SmimeKeyRecord[],
|
||||
): RecipientMatch[] {
|
||||
const matches: RecipientMatch[] = [];
|
||||
|
||||
for (let i = 0; i < envelopedData.recipientInfos.length; i++) {
|
||||
const ri = envelopedData.recipientInfos[i];
|
||||
|
||||
// RecipientInfo is a wrapper: variant=1 → KeyTransRecipientInfo
|
||||
const ktri = ri instanceof pkijs.KeyTransRecipientInfo
|
||||
? ri
|
||||
: (ri as { variant?: number; value?: unknown }).variant === 1 && (ri as { value?: unknown }).value instanceof pkijs.KeyTransRecipientInfo
|
||||
? (ri as { value: pkijs.KeyTransRecipientInfo }).value
|
||||
: null;
|
||||
|
||||
if (ktri) {
|
||||
for (const keyRecord of keyRecords) {
|
||||
if (matchesKeyTransRecipient(ktri, keyRecord)) {
|
||||
matches.push({ keyRecord, recipientIndex: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function matchesKeyTransRecipient(
|
||||
recipientInfo: pkijs.KeyTransRecipientInfo,
|
||||
keyRecord: SmimeKeyRecord,
|
||||
): boolean {
|
||||
const rid = recipientInfo.rid;
|
||||
|
||||
// IssuerAndSerialNumber matching
|
||||
if (rid instanceof pkijs.IssuerAndSerialNumber) {
|
||||
try {
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
if (certAsn1.offset === -1) return false;
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
// Compare serial numbers
|
||||
const ridSerial = Buffer.from(rid.serialNumber.valueBlock.valueHexView).toString('hex');
|
||||
const certSerial = Buffer.from(cert.serialNumber.valueBlock.valueHexView).toString('hex');
|
||||
if (ridSerial !== certSerial) return false;
|
||||
|
||||
// Compare issuers (compare DER encoding)
|
||||
const ridIssuerDer = rid.issuer.toSchema().toBER(false);
|
||||
const certIssuerDer = cert.issuer.toSchema().toBER(false);
|
||||
return arraysEqual(new Uint8Array(ridIssuerDer), new Uint8Array(certIssuerDer));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// SubjectKeyIdentifier matching
|
||||
if (rid instanceof asn1js.OctetString) {
|
||||
try {
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
if (certAsn1.offset === -1) return false;
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
// Find the SubjectKeyIdentifier extension
|
||||
const skiExt = cert.extensions?.find(
|
||||
ext => ext.extnID === '2.5.29.14', // id-ce-subjectKeyIdentifier
|
||||
);
|
||||
if (!skiExt) return false;
|
||||
|
||||
const skiValue = asn1js.fromBER(skiExt.extnValue.valueBlock.valueHexView);
|
||||
if (skiValue.offset === -1) return false;
|
||||
const ski = (skiValue.result as asn1js.OctetString).valueBlock.valueHexView;
|
||||
|
||||
return arraysEqual(
|
||||
new Uint8Array(ski),
|
||||
new Uint8Array(rid.valueBlock.valueHexView),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function decryptWithKey(
|
||||
envelopedData: pkijs.EnvelopedData,
|
||||
recipientIndex: number,
|
||||
privateKey: CryptoKey,
|
||||
keyRecord: SmimeKeyRecord,
|
||||
): Promise<ArrayBuffer> {
|
||||
// Parse the certificate for pkijs
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
// Use withLinerEngine to set the global pkijs engine to webcrypto-liner.
|
||||
// This is required because pkijs internally may use getEngine() for
|
||||
// OID lookups and crypto operations. Without this, 3DES-encrypted
|
||||
// messages fail because the default engine doesn't know about DES-EDE3-CBC.
|
||||
return withLinerEngine(async () => {
|
||||
const cryptoEngine = getLinerCryptoEngine();
|
||||
|
||||
return envelopedData.decrypt(
|
||||
recipientIndex,
|
||||
{
|
||||
recipientCertificate: cert,
|
||||
recipientPrivateKey: privateKey,
|
||||
},
|
||||
cryptoEngine,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Detect S/MIME content in an email message.
|
||||
*
|
||||
* Checks Content-Type headers, bodyStructure, and attachment metadata
|
||||
* to determine if a message contains CMS signed or encrypted content.
|
||||
*/
|
||||
|
||||
export type SmimeContentType =
|
||||
| 'enveloped-data' // encrypted
|
||||
| 'signed-data' // opaque signed
|
||||
| 'detached-sig' // multipart/signed (deferred in v1)
|
||||
| null;
|
||||
|
||||
export interface SmimeDetectionResult {
|
||||
/** Primary S/MIME content type detected, or null if none */
|
||||
type: SmimeContentType;
|
||||
/** The blobId to fetch for CMS processing (enveloped-data or signed-data) */
|
||||
blobId?: string;
|
||||
/** The partId containing the CMS data */
|
||||
partId?: string;
|
||||
/** Whether this is a v1-supported type */
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
interface EmailBodyPart {
|
||||
partId?: string;
|
||||
blobId?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
disposition?: string;
|
||||
subParts?: EmailBodyPart[];
|
||||
headers?: Array<{ name: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect S/MIME content from email metadata.
|
||||
*
|
||||
* @param contentType - The top-level Content-Type header value
|
||||
* @param bodyStructure - The JMAP bodyStructure tree
|
||||
* @param attachments - Flat list of attachment parts (from `attachments` property)
|
||||
*/
|
||||
export function detectSmime(
|
||||
contentType?: string,
|
||||
bodyStructure?: EmailBodyPart | null,
|
||||
attachments?: EmailBodyPart[],
|
||||
): SmimeDetectionResult {
|
||||
const noResult: SmimeDetectionResult = { type: null, supported: false };
|
||||
|
||||
// 1. Check top-level Content-Type header
|
||||
if (contentType) {
|
||||
const ct = contentType.toLowerCase();
|
||||
|
||||
if (ct.includes('application/pkcs7-mime') || ct.includes('application/x-pkcs7-mime')) {
|
||||
if (ct.includes('smime-type=enveloped-data')) {
|
||||
const part = findCmsPart(bodyStructure, 'enveloped-data');
|
||||
return {
|
||||
type: 'enveloped-data',
|
||||
blobId: part?.blobId,
|
||||
partId: part?.partId,
|
||||
supported: true,
|
||||
};
|
||||
}
|
||||
if (ct.includes('smime-type=signed-data')) {
|
||||
const part = findCmsPart(bodyStructure, 'signed-data');
|
||||
return {
|
||||
type: 'signed-data',
|
||||
blobId: part?.blobId,
|
||||
partId: part?.partId,
|
||||
supported: true,
|
||||
};
|
||||
}
|
||||
// Generic pkcs7-mime without explicit smime-type - try bodyStructure
|
||||
const part = findCmsPart(bodyStructure, null);
|
||||
if (part) {
|
||||
const partType = inferSmimeType(part);
|
||||
return {
|
||||
type: partType,
|
||||
blobId: part.blobId,
|
||||
partId: part.partId,
|
||||
supported: partType === 'enveloped-data' || partType === 'signed-data',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (ct.includes('multipart/signed') && ct.includes('application/pkcs7-signature')) {
|
||||
return { type: 'detached-sig', supported: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Walk bodyStructure tree
|
||||
if (bodyStructure) {
|
||||
const result = walkBodyStructure(bodyStructure);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 3. Check attachment list for .p7m files
|
||||
if (attachments) {
|
||||
for (const att of attachments) {
|
||||
const type = att.type?.toLowerCase() || '';
|
||||
const name = att.name?.toLowerCase() || '';
|
||||
|
||||
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
||||
const smimeType = inferSmimeTypeFromContentType(type);
|
||||
return {
|
||||
type: smimeType,
|
||||
blobId: att.blobId,
|
||||
partId: att.partId,
|
||||
supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
|
||||
};
|
||||
}
|
||||
|
||||
if (name.endsWith('.p7m')) {
|
||||
return {
|
||||
type: 'enveloped-data', // .p7m is ambiguous but commonly encrypted
|
||||
blobId: att.blobId,
|
||||
partId: att.partId,
|
||||
supported: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (name.endsWith('.p7s')) {
|
||||
return { type: 'detached-sig', blobId: att.blobId, partId: att.partId, supported: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return noResult;
|
||||
}
|
||||
|
||||
function walkBodyStructure(part: EmailBodyPart): SmimeDetectionResult | null {
|
||||
const type = part.type?.toLowerCase() || '';
|
||||
|
||||
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
||||
const smimeType = inferSmimeTypeFromContentType(type);
|
||||
return {
|
||||
type: smimeType,
|
||||
blobId: part.blobId,
|
||||
partId: part.partId,
|
||||
supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'multipart/signed') {
|
||||
// Check for pkcs7-signature protocol in subparts
|
||||
if (part.subParts?.some(sp => sp.type?.toLowerCase().includes('application/pkcs7-signature'))) {
|
||||
return { type: 'detached-sig', supported: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (part.subParts) {
|
||||
for (const sub of part.subParts) {
|
||||
const result = walkBodyStructure(sub);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCmsPart(bodyStructure: EmailBodyPart | null | undefined, smimeType: string | null): EmailBodyPart | null {
|
||||
if (!bodyStructure) return null;
|
||||
|
||||
const type = bodyStructure.type?.toLowerCase() || '';
|
||||
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
||||
// JMAP bodyStructure.type may not include smime-type parameter,
|
||||
// so accept any pkcs7-mime part when the smime-type was already
|
||||
// determined from the Content-Type header.
|
||||
return bodyStructure;
|
||||
}
|
||||
|
||||
if (bodyStructure.subParts) {
|
||||
for (const sub of bodyStructure.subParts) {
|
||||
const found = findCmsPart(sub, smimeType);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferSmimeType(part: EmailBodyPart): SmimeContentType {
|
||||
return inferSmimeTypeFromContentType(part.type || '');
|
||||
}
|
||||
|
||||
function inferSmimeTypeFromContentType(ct: string): SmimeContentType {
|
||||
const lower = ct.toLowerCase();
|
||||
if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data';
|
||||
if (lower.includes('smime-type=signed-data')) return 'signed-data';
|
||||
// Default for generic pkcs7-mime: assume enveloped-data (most common)
|
||||
if (lower.includes('application/pkcs7-mime') || lower.includes('application/x-pkcs7-mime')) {
|
||||
return 'enveloped-data';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import * as pkijs from 'pkijs';
|
||||
import { parseCertificateDer } from './certificate-utils';
|
||||
|
||||
/**
|
||||
* Produce CMS EnvelopedData for the given MIME content.
|
||||
*
|
||||
* Content type: application/pkcs7-mime; smime-type=enveloped-data
|
||||
*
|
||||
* Always includes the sender's cert so the sender can decrypt their Sent mail.
|
||||
*/
|
||||
export async function smimeEncrypt(
|
||||
mimeBytes: Uint8Array,
|
||||
recipientCertsDer: ArrayBuffer[],
|
||||
senderCertDer: ArrayBuffer,
|
||||
useAes128?: boolean,
|
||||
): Promise<Blob> {
|
||||
// Combine recipient + sender certs, deduplicate by DER bytes
|
||||
const allCertDers = deduplicateCerts([...recipientCertsDer, senderCertDer]);
|
||||
|
||||
if (allCertDers.length === 0) {
|
||||
throw new Error('No recipient certificates provided');
|
||||
}
|
||||
|
||||
// Parse all certificates
|
||||
const recipientCerts = allCertDers.map((der) => parseCertificateDer(der));
|
||||
|
||||
// Build EnvelopedData
|
||||
const cmsEnveloped = new pkijs.EnvelopedData();
|
||||
|
||||
// Add recipient info for each certificate
|
||||
for (const cert of recipientCerts) {
|
||||
cmsEnveloped.addRecipientByCertificate(cert, {
|
||||
oaepHashAlgorithm: 'SHA-256',
|
||||
}, undefined, new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
}));
|
||||
}
|
||||
|
||||
// Encrypt the content
|
||||
const contentEncryptionAlgorithm = useAes128
|
||||
? { name: 'AES-GCM', length: 128 }
|
||||
: { name: 'AES-GCM', length: 256 };
|
||||
|
||||
await cmsEnveloped.encrypt(contentEncryptionAlgorithm, mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength) as ArrayBuffer, new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
}));
|
||||
|
||||
// Wrap in ContentInfo
|
||||
const cms = new pkijs.ContentInfo({
|
||||
contentType: '1.2.840.113549.1.7.3', // id-envelopedData
|
||||
content: cmsEnveloped.toSchema(),
|
||||
});
|
||||
|
||||
const cmsBytes = cms.toSchema().toBER(false);
|
||||
return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=enveloped-data' });
|
||||
}
|
||||
|
||||
/** Remove duplicate DER-encoded certificates based on byte equality. */
|
||||
function deduplicateCerts(certs: ArrayBuffer[]): ArrayBuffer[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ArrayBuffer[] = [];
|
||||
for (const cert of certs) {
|
||||
const key = arrayBufferToHex(cert);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(cert);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function arrayBufferToHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { parseCertificateDer } from './certificate-utils';
|
||||
|
||||
/**
|
||||
* Produce an opaque CMS SignedData wrapping the given MIME content.
|
||||
*
|
||||
* Content type: application/pkcs7-mime; smime-type=signed-data
|
||||
* This is the "opaque" form - the content is embedded inside the CMS structure.
|
||||
*/
|
||||
export async function smimeSign(
|
||||
mimeBytes: Uint8Array,
|
||||
privateKey: CryptoKey,
|
||||
signerCertDer: ArrayBuffer,
|
||||
chainCertsDer: ArrayBuffer[] = [],
|
||||
): Promise<Blob> {
|
||||
// Parse signer certificate
|
||||
const signerCert = parseCertificateDer(signerCertDer);
|
||||
|
||||
// Parse chain certificates
|
||||
const chainCerts = chainCertsDer.map((der) => parseCertificateDer(der));
|
||||
|
||||
// Build CMS SignedData
|
||||
const cmsSigned = new pkijs.SignedData({
|
||||
version: 1,
|
||||
encapContentInfo: new pkijs.EncapsulatedContentInfo({
|
||||
eContentType: '1.2.840.113549.1.7.1', // id-data
|
||||
eContent: new asn1js.OctetString({ valueHex: new Uint8Array(mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength)) }),
|
||||
}),
|
||||
signerInfos: [
|
||||
new pkijs.SignerInfo({
|
||||
version: 1,
|
||||
sid: new pkijs.IssuerAndSerialNumber({
|
||||
issuer: signerCert.issuer,
|
||||
serialNumber: signerCert.serialNumber,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
certificates: [signerCert, ...chainCerts],
|
||||
});
|
||||
|
||||
// Determine signing algorithm from the key
|
||||
const algorithm = privateKey.algorithm;
|
||||
const hashAlgorithm = 'SHA-256';
|
||||
|
||||
let _signAlg: string;
|
||||
if (algorithm.name === 'RSASSA-PKCS1-v1_5' || algorithm.name === 'RSA-PSS') {
|
||||
_signAlg = algorithm.name;
|
||||
} else if (algorithm.name === 'ECDSA') {
|
||||
_signAlg = 'ECDSA';
|
||||
} else {
|
||||
_signAlg = 'RSASSA-PKCS1-v1_5';
|
||||
}
|
||||
|
||||
// Sign
|
||||
await cmsSigned.sign(privateKey, 0, hashAlgorithm, undefined, new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
}));
|
||||
|
||||
// Wrap in ContentInfo
|
||||
const cms = new pkijs.ContentInfo({
|
||||
contentType: '1.2.840.113549.1.7.2', // id-signedData
|
||||
content: cmsSigned.toSchema(true),
|
||||
});
|
||||
|
||||
const cmsBytes = cms.toSchema().toBER(false);
|
||||
return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=signed-data' });
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||
*
|
||||
* Performs cryptographic signature validation, cert validity checks,
|
||||
* and trust-chain verification.
|
||||
*/
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { extractCertificateInfo } from './certificate-utils';
|
||||
import type { SmimeStatus, SmimePublicCert } from './types';
|
||||
|
||||
export interface VerificationResult {
|
||||
/** The inner MIME bytes extracted from the opaque SignedData */
|
||||
mimeBytes: Uint8Array;
|
||||
/** Full S/MIME status for display */
|
||||
status: SmimeStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a CMS SignedData structure and extract the encapsulated content.
|
||||
*
|
||||
* @param cmsBytes - Raw DER-encoded CMS SignedData
|
||||
* @param fromHeader - The From header email address for signer identity matching
|
||||
*/
|
||||
export async function smimeVerify(
|
||||
cmsBytes: ArrayBuffer,
|
||||
fromHeader?: string,
|
||||
): Promise<VerificationResult> {
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const signedData = extractSignedData(contentInfo);
|
||||
|
||||
// Extract inner content
|
||||
const innerContent = extractInnerContent(signedData);
|
||||
|
||||
// Extract signer certificate
|
||||
const signerCert = extractSignerCertificate(signedData);
|
||||
if (!signerCert) {
|
||||
return {
|
||||
mimeBytes: innerContent,
|
||||
status: {
|
||||
isSigned: true,
|
||||
isEncrypted: false,
|
||||
signatureValid: false,
|
||||
signatureError: 'Signer certificate not found in CMS structure',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the signature cryptographically
|
||||
let signatureValid = false;
|
||||
let signatureError: string | undefined;
|
||||
|
||||
try {
|
||||
const cryptoEngine = new pkijs.CryptoEngine({
|
||||
crypto: crypto,
|
||||
subtle: crypto.subtle,
|
||||
name: 'webcrypto',
|
||||
});
|
||||
|
||||
const verifyResult = await signedData.verify(
|
||||
{
|
||||
signer: 0,
|
||||
checkChain: true,
|
||||
},
|
||||
cryptoEngine,
|
||||
);
|
||||
signatureValid = verifyResult;
|
||||
} catch (err) {
|
||||
signatureError = err instanceof Error ? err.message : 'Signature verification failed';
|
||||
}
|
||||
|
||||
// Extract certificate info for display
|
||||
const certDer = signerCert.toSchema(true).toBER(false);
|
||||
const certInfo = await extractCertificateInfo(signerCert, certDer);
|
||||
|
||||
// Check certificate validity period
|
||||
const now = new Date();
|
||||
const notBefore = new Date(certInfo.notBefore);
|
||||
const notAfter = new Date(certInfo.notAfter);
|
||||
const certExpired = now > notAfter;
|
||||
const certNotYetValid = now < notBefore;
|
||||
|
||||
if (certExpired && !signatureError) {
|
||||
signatureError = 'Signer certificate has expired';
|
||||
}
|
||||
if (certNotYetValid && !signatureError) {
|
||||
signatureError = 'Signer certificate is not yet valid';
|
||||
}
|
||||
|
||||
// Build the signer public cert object
|
||||
const signerEmail = certInfo.emailAddresses[0] ?? '';
|
||||
const signerPublicCert: SmimePublicCert = {
|
||||
id: `signer-${certInfo.fingerprint}`,
|
||||
email: signerEmail.toLowerCase(),
|
||||
certificate: certDer,
|
||||
issuer: certInfo.issuer,
|
||||
subject: certInfo.subject,
|
||||
notBefore: certInfo.notBefore,
|
||||
notAfter: certInfo.notAfter,
|
||||
fingerprint: certInfo.fingerprint,
|
||||
source: 'signed-email',
|
||||
};
|
||||
|
||||
// Check signer identity vs From header
|
||||
let signerEmailMatch: boolean | undefined;
|
||||
if (fromHeader && signerEmail) {
|
||||
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
||||
}
|
||||
|
||||
// Detect self-signed certificates (issuer === subject)
|
||||
const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
|
||||
const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
|
||||
const selfSigned = arraysEqual(issuerDer, subjectDer);
|
||||
|
||||
return {
|
||||
mimeBytes: innerContent,
|
||||
status: {
|
||||
isSigned: true,
|
||||
isEncrypted: false,
|
||||
signatureValid: signatureValid && !certExpired && !certNotYetValid,
|
||||
signatureError,
|
||||
signerCert: signerPublicCert,
|
||||
signerEmailMatch,
|
||||
selfSigned,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo {
|
||||
const asn1 = asn1js.fromBER(der);
|
||||
if (asn1.offset === -1) {
|
||||
throw new Error('Invalid ASN.1 data - cannot parse CMS structure');
|
||||
}
|
||||
return new pkijs.ContentInfo({ schema: asn1.result });
|
||||
}
|
||||
|
||||
function extractSignedData(contentInfo: pkijs.ContentInfo): pkijs.SignedData {
|
||||
// OID 1.2.840.113549.1.7.2 = signed-data
|
||||
if (contentInfo.contentType !== '1.2.840.113549.1.7.2') {
|
||||
throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`);
|
||||
}
|
||||
return new pkijs.SignedData({ schema: contentInfo.content });
|
||||
}
|
||||
|
||||
function extractInnerContent(signedData: pkijs.SignedData): Uint8Array {
|
||||
const eContent = signedData.encapContentInfo?.eContent;
|
||||
if (!eContent) {
|
||||
throw new Error('No encapsulated content in SignedData (detached signature not supported)');
|
||||
}
|
||||
|
||||
if (eContent instanceof asn1js.OctetString) {
|
||||
// Constructed OCTET STRING: data lives in child OctetStrings
|
||||
const children = (eContent.valueBlock as unknown as { value?: asn1js.OctetString[] }).value;
|
||||
if (children?.length) {
|
||||
const chunks = children.map(c => new Uint8Array(c.valueBlock.valueHexView));
|
||||
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Primitive OCTET STRING: data is directly in valueHexView
|
||||
return new Uint8Array(eContent.valueBlock.valueHexView);
|
||||
}
|
||||
|
||||
throw new Error('Unable to extract content from SignedData');
|
||||
}
|
||||
|
||||
function extractSignerCertificate(signedData: pkijs.SignedData): pkijs.Certificate | null {
|
||||
if (!signedData.signerInfos?.length || !signedData.certificates?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const signerInfo = signedData.signerInfos[0];
|
||||
const sid = signerInfo.sid;
|
||||
|
||||
// IssuerAndSerialNumber matching
|
||||
if (sid instanceof pkijs.IssuerAndSerialNumber) {
|
||||
for (const certItem of signedData.certificates) {
|
||||
if (!(certItem instanceof pkijs.Certificate)) continue;
|
||||
const cert = certItem;
|
||||
|
||||
// Compare serial numbers
|
||||
const sidSerial = toHex(sid.serialNumber.valueBlock.valueHexView);
|
||||
const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView);
|
||||
if (sidSerial !== certSerial) continue;
|
||||
|
||||
// Compare issuers
|
||||
const sidIssuerDer = new Uint8Array(sid.issuer.toSchema().toBER(false));
|
||||
const certIssuerDer = new Uint8Array(cert.issuer.toSchema().toBER(false));
|
||||
if (arraysEqual(sidIssuerDer, certIssuerDer)) {
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If only one certificate is present, use it as fallback
|
||||
if (signedData.certificates.length === 1) {
|
||||
const cert = signedData.certificates[0];
|
||||
if (cert instanceof pkijs.Certificate) return cert;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toHex(buffer: ArrayBuffer | ArrayBufferView): string {
|
||||
const bytes = buffer instanceof ArrayBuffer
|
||||
? new Uint8Array(buffer)
|
||||
: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/** Stored record for an imported S/MIME private key + certificate. */
|
||||
export interface SmimeKeyRecord {
|
||||
id: string;
|
||||
accountId?: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
||||
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
||||
encryptedPrivateKey: ArrayBuffer; // AES-GCM wrapped PKCS#8 bytes
|
||||
salt: ArrayBuffer; // PBKDF2 salt
|
||||
iv: ArrayBuffer; // AES-GCM IV
|
||||
kdfIterations: number;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
serialNumber: string;
|
||||
notBefore: string; // ISO 8601
|
||||
notAfter: string; // ISO 8601
|
||||
fingerprint: string; // SHA-256 hex of DER cert
|
||||
algorithm: string; // e.g. "RSA-2048", "RSA-4096", "ECDSA-P256"
|
||||
capabilities: SmimeKeyCapabilities;
|
||||
}
|
||||
|
||||
/** What a certificate can be used for based on KeyUsage/ExtendedKeyUsage. */
|
||||
export interface SmimeKeyCapabilities {
|
||||
canSign: boolean;
|
||||
canEncrypt: boolean;
|
||||
}
|
||||
|
||||
/** Runtime-only unlocked private key handle (never persisted). */
|
||||
export interface SmimeUnlockedKey {
|
||||
id: string;
|
||||
email: string;
|
||||
privateKey: CryptoKey; // imported as non-extractable
|
||||
}
|
||||
|
||||
/** A recipient or contact public certificate. */
|
||||
export interface SmimePublicCert {
|
||||
id: string;
|
||||
accountId?: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509
|
||||
issuer: string;
|
||||
subject: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
fingerprint: string;
|
||||
source: 'manual' | 'contact' | 'signed-email';
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
/** Status of S/MIME processing for a single email message. */
|
||||
export interface SmimeStatus {
|
||||
isSigned: boolean;
|
||||
isEncrypted: boolean;
|
||||
signatureValid?: boolean;
|
||||
signatureError?: string;
|
||||
signerCert?: SmimePublicCert;
|
||||
signerEmailMatch?: boolean;
|
||||
/** True when the signer certificate is self-signed (not chained to a trusted CA). */
|
||||
selfSigned?: boolean;
|
||||
decryptionSuccess?: boolean;
|
||||
decryptionError?: string;
|
||||
unsupportedReason?: string;
|
||||
}
|
||||
|
||||
/** Metadata extracted from a parsed X.509 certificate. */
|
||||
export interface CertificateInfo {
|
||||
subject: string;
|
||||
issuer: string;
|
||||
serialNumber: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
keyUsage?: string[];
|
||||
extendedKeyUsage?: string[];
|
||||
emailAddresses: string[];
|
||||
capabilities: SmimeKeyCapabilities;
|
||||
}
|
||||
|
||||
/** Result of PKCS#12 import parsing. */
|
||||
export interface Pkcs12ImportResult {
|
||||
keyRecord: SmimeKeyRecord;
|
||||
certInfo: CertificateInfo;
|
||||
}
|
||||
Reference in New Issue
Block a user