- 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.
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
// @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);
|
|
});
|
|
});
|