162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||
|
|
import * as crypto from 'node:crypto';
|
||
|
|
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
||
|
|
|
||
|
|
// --- createRsaPrivateKey ---
|
||
|
|
|
||
|
|
tap.test('createRsaPrivateKey returns valid PEM', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
expect(pem).toStartWith('-----BEGIN PRIVATE KEY-----');
|
||
|
|
expect(pem).toInclude('-----END PRIVATE KEY-----');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createRsaPrivateKey creates RSA key type', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const keyObj = crypto.createPrivateKey(pem);
|
||
|
|
expect(keyObj.asymmetricKeyType).toEqual('rsa');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createRsaPrivateKey respects modulusLength', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey(4096);
|
||
|
|
const keyObj = crypto.createPrivateKey(pem);
|
||
|
|
expect(keyObj.asymmetricKeyDetails!.modulusLength).toEqual(4096);
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- getJwk ---
|
||
|
|
|
||
|
|
tap.test('getJwk returns sorted keys {e, kty, n}', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jwk = AcmeCrypto.getJwk(pem);
|
||
|
|
const keys = Object.keys(jwk);
|
||
|
|
expect(keys).toEqual(['e', 'kty', 'n']);
|
||
|
|
expect(jwk.kty).toEqual('RSA');
|
||
|
|
expect(typeof jwk.e).toEqual('string');
|
||
|
|
expect(typeof jwk.n).toEqual('string');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('getJwk is deterministic for same key', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jwk1 = AcmeCrypto.getJwk(pem);
|
||
|
|
const jwk2 = AcmeCrypto.getJwk(pem);
|
||
|
|
expect(JSON.stringify(jwk1)).toEqual(JSON.stringify(jwk2));
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- getJwkThumbprint ---
|
||
|
|
|
||
|
|
tap.test('getJwkThumbprint matches manual SHA-256 computation', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jwk = AcmeCrypto.getJwk(pem);
|
||
|
|
const thumbprint = AcmeCrypto.getJwkThumbprint(jwk);
|
||
|
|
|
||
|
|
// Manual computation
|
||
|
|
const canonical = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n });
|
||
|
|
const expected = crypto.createHash('sha256').update(canonical).digest().toString('base64url');
|
||
|
|
expect(thumbprint).toEqual(expected);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('getJwkThumbprint is base64url format (no +, /, =)', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jwk = AcmeCrypto.getJwk(pem);
|
||
|
|
const thumbprint = AcmeCrypto.getJwkThumbprint(jwk);
|
||
|
|
expect(thumbprint).not.toInclude('+');
|
||
|
|
expect(thumbprint).not.toInclude('/');
|
||
|
|
expect(thumbprint).not.toInclude('=');
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- createJws ---
|
||
|
|
|
||
|
|
tap.test('createJws returns correct structure', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jwk = AcmeCrypto.getJwk(pem);
|
||
|
|
const jws = AcmeCrypto.createJws(pem, 'https://acme.example/new-acct', { foo: 'bar' }, {
|
||
|
|
nonce: 'test-nonce',
|
||
|
|
jwk,
|
||
|
|
});
|
||
|
|
expect(typeof jws.protected).toEqual('string');
|
||
|
|
expect(typeof jws.payload).toEqual('string');
|
||
|
|
expect(typeof jws.signature).toEqual('string');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createJws protected header contains alg, nonce, url, jwk', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jwk = AcmeCrypto.getJwk(pem);
|
||
|
|
const jws = AcmeCrypto.createJws(pem, 'https://acme.example/new-acct', { foo: 'bar' }, {
|
||
|
|
nonce: 'test-nonce',
|
||
|
|
jwk,
|
||
|
|
});
|
||
|
|
const header = JSON.parse(Buffer.from(jws.protected, 'base64url').toString());
|
||
|
|
expect(header.alg).toEqual('RS256');
|
||
|
|
expect(header.nonce).toEqual('test-nonce');
|
||
|
|
expect(header.url).toEqual('https://acme.example/new-acct');
|
||
|
|
expect(header.jwk).toBeTruthy();
|
||
|
|
expect(header.kid).toBeFalsy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createJws uses kid when provided instead of jwk', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jws = AcmeCrypto.createJws(pem, 'https://acme.example/order', { test: 1 }, {
|
||
|
|
nonce: 'nonce-2',
|
||
|
|
kid: 'https://acme.example/acct/1',
|
||
|
|
});
|
||
|
|
const header = JSON.parse(Buffer.from(jws.protected, 'base64url').toString());
|
||
|
|
expect(header.kid).toEqual('https://acme.example/acct/1');
|
||
|
|
expect(header.jwk).toBeFalsy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createJws POST-as-GET produces empty payload', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jws = AcmeCrypto.createJws(pem, 'https://acme.example/order/1', null, {
|
||
|
|
nonce: 'nonce-3',
|
||
|
|
kid: 'https://acme.example/acct/1',
|
||
|
|
});
|
||
|
|
expect(jws.payload).toEqual('');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createJws signature is verifiable', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const jwk = AcmeCrypto.getJwk(pem);
|
||
|
|
const jws = AcmeCrypto.createJws(pem, 'https://acme.example/test', { val: 1 }, {
|
||
|
|
nonce: 'nonce-v',
|
||
|
|
jwk,
|
||
|
|
});
|
||
|
|
|
||
|
|
const sigInput = `${jws.protected}.${jws.payload}`;
|
||
|
|
const pubKey = crypto.createPublicKey(pem);
|
||
|
|
const verified = crypto.verify(
|
||
|
|
'sha256',
|
||
|
|
Buffer.from(sigInput),
|
||
|
|
pubKey,
|
||
|
|
Buffer.from(jws.signature, 'base64url'),
|
||
|
|
);
|
||
|
|
expect(verified).toBeTrue();
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- createCsr ---
|
||
|
|
|
||
|
|
tap.test('createCsr returns [keyPem, csrPem] with valid PEM formats', async () => {
|
||
|
|
const [keyPem, csrPem] = await AcmeCrypto.createCsr({ commonName: 'example.com' });
|
||
|
|
expect(keyPem).toStartWith('-----BEGIN PRIVATE KEY-----');
|
||
|
|
expect(csrPem).toInclude('CERTIFICATE REQUEST');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createCsr uses existing key when provided', async () => {
|
||
|
|
const existingKey = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const [keyPem, csrPem] = await AcmeCrypto.createCsr({ commonName: 'example.com' }, existingKey);
|
||
|
|
expect(keyPem).toEqual(existingKey);
|
||
|
|
expect(csrPem).toInclude('CERTIFICATE REQUEST');
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- pemToBuffer ---
|
||
|
|
|
||
|
|
tap.test('pemToBuffer strips headers and returns Buffer', async () => {
|
||
|
|
const pem = AcmeCrypto.createRsaPrivateKey();
|
||
|
|
const buf = AcmeCrypto.pemToBuffer(pem);
|
||
|
|
expect(buf).toBeInstanceOf(Buffer);
|
||
|
|
expect(buf.length).toBeGreaterThan(0);
|
||
|
|
// Verify it doesn't contain PEM header text
|
||
|
|
const str = buf.toString('utf-8');
|
||
|
|
expect(str).not.toInclude('-----BEGIN');
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|