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,219 @@
|
||||
/**
|
||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||
*
|
||||
* v1 performs cryptographic signature validation and cert validity checks
|
||||
* but does NOT implement full trust-chain or revocation validation.
|
||||
*/
|
||||
|
||||
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: false, // v1: no trust-chain validation
|
||||
},
|
||||
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();
|
||||
}
|
||||
|
||||
return {
|
||||
mimeBytes: innerContent,
|
||||
status: {
|
||||
isSigned: true,
|
||||
isEncrypted: false,
|
||||
signatureValid: signatureValid && !certExpired && !certNotYetValid,
|
||||
signatureError,
|
||||
signerCert: signerPublicCert,
|
||||
signerEmailMatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
Reference in New Issue
Block a user