- 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.
215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|