mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 00:07:28 +00:00
134 lines
6.2 KiB
TypeScript
134 lines
6.2 KiB
TypeScript
// Throwaway keys and certificates for the PGP / S/MIME tests (#58), made
|
|
// fresh in each run. Nothing here belongs to a real person.
|
|
import * as nodeCrypto from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import * as asn1js from 'asn1js';
|
|
import * as pkijs from 'pkijs';
|
|
import * as openpgp from 'openpgp';
|
|
import { setNodeStyleCrypto, type NodeStyleCrypto } from '../provider';
|
|
import { resetSmimeEngine } from '../smime/engine';
|
|
|
|
/**
|
|
* Node's crypto stands in for react-native-quick-crypto. Newer Node releases
|
|
* refuse RSA PKCS#1 v1.5 private decryption (CVE-2023-46809) while OpenSSL
|
|
* under quick-crypto still does it, so the stand-in decrypts raw and strips
|
|
* the type 2 padding itself.
|
|
*/
|
|
export function useNodeCrypto(): void {
|
|
const standIn = {
|
|
...nodeCrypto,
|
|
privateDecrypt(key: nodeCrypto.PrivateKeyInput & { padding?: number }, buffer: Uint8Array): Uint8Array {
|
|
if (key.padding !== nodeCrypto.constants.RSA_PKCS1_PADDING) return nodeCrypto.privateDecrypt(key, buffer);
|
|
const em = nodeCrypto.privateDecrypt({ ...key, padding: nodeCrypto.constants.RSA_NO_PADDING }, buffer);
|
|
const sep = em.indexOf(0, 2);
|
|
if (em[0] !== 0 || em[1] !== 2 || sep < 10) throw new Error('decryption error');
|
|
return em.subarray(sep + 1);
|
|
},
|
|
};
|
|
setNodeStyleCrypto(() => standIn as unknown as NodeStyleCrypto);
|
|
resetSmimeEngine();
|
|
}
|
|
|
|
export interface TestCert {
|
|
email: string;
|
|
certificate: Uint8Array;
|
|
pkcs8: Uint8Array;
|
|
keyPem: string;
|
|
certPem: string;
|
|
}
|
|
|
|
const engine = () => new pkijs.CryptoEngine({ name: 'test', crypto: globalThis.crypto });
|
|
|
|
/** A self-signed RSA-2048 S/MIME certificate for `email`. */
|
|
export async function makeSmimeCert(email: string, opts: { notAfter?: Date; extraEmails?: string[] } = {}): Promise<TestCert> {
|
|
const subtle = globalThis.crypto.subtle;
|
|
const keys = await subtle.generateKey(
|
|
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
|
true,
|
|
['sign', 'verify'],
|
|
) as CryptoKeyPair;
|
|
const cert = new pkijs.Certificate();
|
|
cert.version = 2;
|
|
cert.serialNumber = new asn1js.Integer({ value: Math.floor(Math.random() * 1e9) + 1 });
|
|
const name = () => [
|
|
new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: `Test ${email}` }) }),
|
|
new pkijs.AttributeTypeAndValue({ type: '1.2.840.113549.1.9.1', value: new asn1js.IA5String({ value: email }) }),
|
|
];
|
|
cert.subject.typesAndValues.push(...name());
|
|
cert.issuer.typesAndValues.push(...name());
|
|
cert.notBefore.value = new Date(Date.now() - 86400_000);
|
|
cert.notAfter.value = opts.notAfter ?? new Date(Date.now() + 365 * 86400_000);
|
|
// keyUsage: digitalSignature + keyEncipherment; SAN rfc822Name.
|
|
const ku = new asn1js.BitString({ valueHex: new Uint8Array([0xa0]).buffer, unusedBits: 5 });
|
|
cert.extensions = [
|
|
new pkijs.Extension({ extnID: '2.5.29.15', critical: true, extnValue: ku.toBER(false), parsedValue: ku }),
|
|
new pkijs.Extension({
|
|
extnID: '2.5.29.17',
|
|
extnValue: new pkijs.GeneralNames({
|
|
names: [email, ...(opts.extraEmails ?? [])].map((value) => new pkijs.GeneralName({ type: 1, value })),
|
|
}).toSchema().toBER(false),
|
|
}),
|
|
];
|
|
await cert.subjectPublicKeyInfo.importKey(keys.publicKey, engine());
|
|
await cert.sign(keys.privateKey, 'SHA-256', engine());
|
|
const der = new Uint8Array(cert.toSchema().toBER(false));
|
|
const pkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', keys.privateKey));
|
|
const pem = (label: string, bytes: Uint8Array) =>
|
|
`-----BEGIN ${label}-----\n${Buffer.from(bytes).toString('base64').replace(/(.{64})/g, '$1\n').trim()}\n-----END ${label}-----\n`;
|
|
return { email, certificate: der, pkcs8, keyPem: pem('PRIVATE KEY', pkcs8), certPem: pem('CERTIFICATE', der) };
|
|
}
|
|
|
|
/** A fresh curve25519 PGP key pair for `email`, protected with `passphrase`. */
|
|
export async function makePgpKey(email: string, passphrase = 'secret', type: 'ecc' | 'rsa' = 'ecc') {
|
|
const { privateKey, publicKey } = await openpgp.generateKey({
|
|
...(type === 'ecc' ? { type: 'ecc' as const, curve: 'curve25519Legacy' as const } : { type: 'rsa' as const, rsaBits: 2048 }),
|
|
userIDs: [{ name: `Test ${email}`, email }],
|
|
passphrase,
|
|
format: 'armored',
|
|
});
|
|
const unlocked = await openpgp.decryptKey({ privateKey: await openpgp.readPrivateKey({ armoredKey: privateKey }), passphrase });
|
|
return { privateKey, publicKey, unlocked, passphrase, email };
|
|
}
|
|
|
|
let opensslChecked: boolean | null = null;
|
|
/** Is the openssl CLI there (interop fixtures)? */
|
|
export function hasOpenssl(): boolean {
|
|
if (opensslChecked === null) {
|
|
try {
|
|
execFileSync('openssl', ['version'], { stdio: 'pipe' });
|
|
opensslChecked = true;
|
|
} catch {
|
|
opensslChecked = false;
|
|
}
|
|
}
|
|
return opensslChecked;
|
|
}
|
|
|
|
/** Run openssl in a scratch dir with the given input files; returns the named output file. */
|
|
export function openssl(args: string[], files: Record<string, string | Uint8Array>, output: string): Uint8Array {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'bulwark-smime-'));
|
|
try {
|
|
for (const [name, content] of Object.entries(files)) writeFileSync(path.join(dir, name), content);
|
|
execFileSync('openssl', args, { cwd: dir, stdio: 'pipe', env: { ...process.env, MSYS_NO_PATHCONV: '1' } });
|
|
return new Uint8Array(readFileSync(path.join(dir, output)));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export const CRLF_MESSAGE = 'Content-Type: text/plain; charset=utf-8\r\n\r\nHello from the S/MIME test fixture.\r\n';
|
|
|
|
/** Wrap CMS DER as an S/MIME message the way a client sends it. */
|
|
export function smimeMessage(cms: Uint8Array, smimeType: string, from = 'sender@example.org'): Uint8Array {
|
|
const b64 = Buffer.from(cms).toString('base64').replace(/(.{76})/g, '$1\r\n');
|
|
return new TextEncoder().encode(
|
|
`From: ${from}\r\nTo: rcpt@example.org\r\nSubject: test\r\nMIME-Version: 1.0\r\n`
|
|
+ `Content-Type: application/pkcs7-mime; smime-type=${smimeType}; name="smime.p7m"\r\n`
|
|
+ 'Content-Transfer-Encoding: base64\r\n\r\n'
|
|
+ `${b64}\r\n`,
|
|
);
|
|
}
|