feat: add S/MIME store for managing key records and public certificates
- Implemented Zustand store for S/MIME functionality, including state management for key records and public certificates. - Added methods for importing PKCS#12 files and public certificates, binding identities to keys, and managing unlocked keys. - Introduced session storage for remembering unlocked keys across sessions. - Enhanced error handling and loading states during data operations.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, beforeEach, vi } 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// @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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
// @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';
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
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);
|
||||
});
|
||||
|
||||
describe('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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
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, savePublicCert, 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,
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: false,
|
||||
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('re-unlocks remembered keys during load', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
const mockDecryptionKey = {} as CryptoKey;
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({ rememberUnlockedKeys: true });
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({
|
||||
signingKey: mockSigningKey,
|
||||
decryptionKey: mockDecryptionKey,
|
||||
});
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(unlockPrivateKey).toHaveBeenCalledWith(mockKeyRecord, 'passphrase');
|
||||
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
|
||||
});
|
||||
|
||||
it('removes stale remembered keys when re-unlock fails', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'bad-pass' }));
|
||||
useSmimeStore.setState({ rememberUnlockedKeys: true });
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
||||
vi.mocked(unlockPrivateKey).mockRejectedValue(new Error('Incorrect passphrase'));
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
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 any,
|
||||
});
|
||||
|
||||
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('stores the passphrase for session rehydration when remember is enabled', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord], rememberUnlockedKeys: true });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBe(
|
||||
JSON.stringify({ 'key-1': 'passphrase' }),
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
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);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
it('locks all keys', () => {
|
||||
sessionStorage.setItem(
|
||||
'smime-unlocked-session',
|
||||
JSON.stringify({ 'key-1': 'one', 'key-2': 'two' }),
|
||||
);
|
||||
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);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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('sets remember unlocked keys and clears when disabled', () => {
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
});
|
||||
|
||||
useSmimeStore.getState().setRememberUnlockedKeys(false);
|
||||
|
||||
expect(useSmimeStore.getState().rememberUnlockedKeys).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user