337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import * as http from 'node:http';
|
|
import * as crypto from 'node:crypto';
|
|
import { AcmeServer } from '../ts_server/index.js';
|
|
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
|
|
|
const TEST_PORT = 14567;
|
|
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
|
|
|
let server: AcmeServer;
|
|
let accountKeyPem: string;
|
|
let accountUrl: string;
|
|
let nonce: string;
|
|
|
|
// Helper: simple HTTP request
|
|
function httpRequest(
|
|
url: string,
|
|
method: string,
|
|
body?: string,
|
|
headers?: Record<string, string>,
|
|
): Promise<{ status: number; headers: Record<string, string>; data: any }> {
|
|
return new Promise((resolve, reject) => {
|
|
const urlObj = new URL(url);
|
|
const options: http.RequestOptions = {
|
|
hostname: urlObj.hostname,
|
|
port: urlObj.port,
|
|
path: urlObj.pathname + urlObj.search,
|
|
method,
|
|
headers: {
|
|
...headers,
|
|
...(body ? { 'Content-Length': Buffer.byteLength(body).toString() } : {}),
|
|
},
|
|
};
|
|
|
|
const req = http.request(options, (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
const responseHeaders: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(res.headers)) {
|
|
if (typeof value === 'string') {
|
|
responseHeaders[key.toLowerCase()] = value;
|
|
} else if (Array.isArray(value)) {
|
|
responseHeaders[key.toLowerCase()] = value[0];
|
|
}
|
|
}
|
|
let data: any;
|
|
const ct = responseHeaders['content-type'] || '';
|
|
if (ct.includes('json') || ct.includes('problem')) {
|
|
try { data = JSON.parse(raw); } catch { data = raw; }
|
|
} else {
|
|
data = raw;
|
|
}
|
|
resolve({ status: res.statusCode || 0, headers: responseHeaders, data });
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(10000, () => req.destroy(new Error('Timeout')));
|
|
if (body) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Helper: signed ACME request
|
|
async function signedRequest(
|
|
url: string,
|
|
payload: any | null,
|
|
options?: { useJwk?: boolean },
|
|
): Promise<{ status: number; headers: Record<string, string>; data: any }> {
|
|
const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { nonce };
|
|
if (options?.useJwk) {
|
|
jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem);
|
|
} else if (accountUrl) {
|
|
jwsOptions.kid = accountUrl;
|
|
} else {
|
|
jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem);
|
|
}
|
|
|
|
const jws = AcmeCrypto.createJws(accountKeyPem, url, payload, jwsOptions);
|
|
const body = JSON.stringify(jws);
|
|
|
|
const response = await httpRequest(url, 'POST', body, {
|
|
'Content-Type': 'application/jose+json',
|
|
});
|
|
|
|
// Save nonce from response
|
|
if (response.headers['replay-nonce']) {
|
|
nonce = response.headers['replay-nonce'];
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// Helper: get a fresh nonce
|
|
async function fetchNonce(): Promise<string> {
|
|
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD');
|
|
return res.headers['replay-nonce'];
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
tap.test('server: start ACME server', async () => {
|
|
server = new AcmeServer({
|
|
port: TEST_PORT,
|
|
baseUrl: BASE_URL,
|
|
challengeVerification: false, // Auto-approve challenges for testing
|
|
caOptions: {
|
|
commonName: 'Test ACME CA',
|
|
certValidityDays: 30,
|
|
},
|
|
});
|
|
await server.start();
|
|
});
|
|
|
|
tap.test('server: GET /directory returns valid directory', async () => {
|
|
const res = await httpRequest(`${BASE_URL}/directory`, 'GET');
|
|
expect(res.status).toEqual(200);
|
|
expect(res.data.newNonce).toEqual(`${BASE_URL}/new-nonce`);
|
|
expect(res.data.newAccount).toEqual(`${BASE_URL}/new-account`);
|
|
expect(res.data.newOrder).toEqual(`${BASE_URL}/new-order`);
|
|
expect(res.data.meta).toBeTruthy();
|
|
});
|
|
|
|
tap.test('server: HEAD /new-nonce returns Replay-Nonce header', async () => {
|
|
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD');
|
|
expect(res.status).toEqual(200);
|
|
expect(res.headers['replay-nonce']).toBeTruthy();
|
|
});
|
|
|
|
tap.test('server: GET /new-nonce returns 204 with Replay-Nonce header', async () => {
|
|
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'GET');
|
|
expect(res.status).toEqual(204);
|
|
expect(res.headers['replay-nonce']).toBeTruthy();
|
|
});
|
|
|
|
tap.test('server: POST /new-account registers an account', async () => {
|
|
accountKeyPem = AcmeCrypto.createRsaPrivateKey();
|
|
nonce = await fetchNonce();
|
|
|
|
const res = await signedRequest(`${BASE_URL}/new-account`, {
|
|
termsOfServiceAgreed: true,
|
|
contact: ['mailto:test@example.com'],
|
|
}, { useJwk: true });
|
|
|
|
expect(res.status).toEqual(201);
|
|
expect(res.headers['location']).toBeTruthy();
|
|
expect(res.data.status).toEqual('valid');
|
|
accountUrl = res.headers['location'];
|
|
});
|
|
|
|
tap.test('server: POST /new-account returns existing account', async () => {
|
|
const res = await signedRequest(`${BASE_URL}/new-account`, {
|
|
termsOfServiceAgreed: true,
|
|
contact: ['mailto:test@example.com'],
|
|
}, { useJwk: true });
|
|
|
|
expect(res.status).toEqual(200);
|
|
expect(res.headers['location']).toEqual(accountUrl);
|
|
});
|
|
|
|
tap.test('server: full certificate issuance flow', async () => {
|
|
// 1. Create order
|
|
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
|
|
identifiers: [{ type: 'dns', value: 'example.com' }],
|
|
});
|
|
|
|
expect(orderRes.status).toEqual(201);
|
|
expect(orderRes.data.status).toEqual('pending');
|
|
expect(orderRes.data.authorizations).toBeTypeOf('object');
|
|
expect(orderRes.data.finalize).toBeTruthy();
|
|
const orderUrl = orderRes.headers['location'];
|
|
const authzUrl = orderRes.data.authorizations[0];
|
|
const finalizeUrl = orderRes.data.finalize;
|
|
|
|
// 2. Get authorization
|
|
const authzRes = await signedRequest(authzUrl, null);
|
|
expect(authzRes.status).toEqual(200);
|
|
expect(authzRes.data.identifier.value).toEqual('example.com');
|
|
expect(authzRes.data.challenges.length).toBeGreaterThan(0);
|
|
|
|
// Pick a challenge (prefer dns-01 since it's always available)
|
|
const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01') || authzRes.data.challenges[0];
|
|
expect(challenge.status).toEqual('pending');
|
|
|
|
// 3. Trigger challenge (auto-approved since challengeVerification is false)
|
|
const challengeRes = await signedRequest(challenge.url, {});
|
|
expect(challengeRes.status).toEqual(200);
|
|
expect(challengeRes.data.status).toEqual('valid');
|
|
|
|
// 4. Poll order — should now be ready
|
|
const orderPollRes = await signedRequest(orderUrl, null);
|
|
expect(orderPollRes.status).toEqual(200);
|
|
expect(orderPollRes.data.status).toEqual('ready');
|
|
|
|
// 5. Create CSR and finalize
|
|
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
|
|
commonName: 'example.com',
|
|
altNames: ['example.com'],
|
|
});
|
|
|
|
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
|
const csrB64url = csrDer.toString('base64url');
|
|
|
|
const finalizeRes = await signedRequest(finalizeUrl, { csr: csrB64url });
|
|
expect(finalizeRes.status).toEqual(200);
|
|
expect(finalizeRes.data.status).toEqual('valid');
|
|
expect(finalizeRes.data.certificate).toBeTruthy();
|
|
|
|
// 6. Download certificate
|
|
const certUrl = finalizeRes.data.certificate;
|
|
const certRes = await signedRequest(certUrl, null);
|
|
expect(certRes.status).toEqual(200);
|
|
expect(certRes.headers['content-type']).toEqual('application/pem-certificate-chain');
|
|
expect(certRes.data).toInclude('BEGIN CERTIFICATE');
|
|
|
|
// Verify it's a valid PEM chain (at least 2 certs: end-entity + CA)
|
|
const certMatches = (certRes.data as string).match(/-----BEGIN CERTIFICATE-----/g);
|
|
expect(certMatches!.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
tap.test('server: wildcard certificate issuance', async () => {
|
|
// Create order with wildcard
|
|
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
|
|
identifiers: [
|
|
{ type: 'dns', value: 'test.org' },
|
|
{ type: 'dns', value: '*.test.org' },
|
|
],
|
|
});
|
|
|
|
expect(orderRes.status).toEqual(201);
|
|
expect(orderRes.data.authorizations.length).toEqual(2);
|
|
const orderUrl = orderRes.headers['location'];
|
|
|
|
// Complete all challenges
|
|
for (const authzUrl of orderRes.data.authorizations) {
|
|
const authzRes = await signedRequest(authzUrl, null);
|
|
// For wildcard, only dns-01 should be available
|
|
const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01');
|
|
expect(challenge).toBeTruthy();
|
|
await signedRequest(challenge.url, {});
|
|
}
|
|
|
|
// Poll order
|
|
const orderPollRes = await signedRequest(orderUrl, null);
|
|
expect(orderPollRes.data.status).toEqual('ready');
|
|
|
|
// Finalize
|
|
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
|
|
commonName: 'test.org',
|
|
altNames: ['test.org', '*.test.org'],
|
|
});
|
|
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
|
const finalizeRes = await signedRequest(orderPollRes.data.finalize, {
|
|
csr: csrDer.toString('base64url'),
|
|
});
|
|
expect(finalizeRes.data.status).toEqual('valid');
|
|
|
|
// Download cert
|
|
const certRes = await signedRequest(finalizeRes.data.certificate, null);
|
|
expect(certRes.data).toInclude('BEGIN CERTIFICATE');
|
|
});
|
|
|
|
tap.test('server: rejects invalid JWS signature', async () => {
|
|
// Create JWS with one key but sign URL intended for a different key
|
|
const otherKey = AcmeCrypto.createRsaPrivateKey();
|
|
const otherNonce = await fetchNonce();
|
|
|
|
const jws = AcmeCrypto.createJws(otherKey, `${BASE_URL}/new-order`, { identifiers: [] }, {
|
|
nonce: otherNonce,
|
|
kid: accountUrl, // Use our account URL but sign with a different key
|
|
});
|
|
|
|
const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), {
|
|
'Content-Type': 'application/jose+json',
|
|
});
|
|
|
|
// Save nonce for next request
|
|
if (res.headers['replay-nonce']) {
|
|
nonce = res.headers['replay-nonce'];
|
|
}
|
|
|
|
expect(res.status).toEqual(403);
|
|
});
|
|
|
|
tap.test('server: rejects expired/bad nonce', async () => {
|
|
const jws = AcmeCrypto.createJws(
|
|
accountKeyPem,
|
|
`${BASE_URL}/new-order`,
|
|
{ identifiers: [{ type: 'dns', value: 'example.com' }] },
|
|
{ nonce: 'definitely-not-a-valid-nonce', kid: accountUrl },
|
|
);
|
|
|
|
const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), {
|
|
'Content-Type': 'application/jose+json',
|
|
});
|
|
|
|
if (res.headers['replay-nonce']) {
|
|
nonce = res.headers['replay-nonce'];
|
|
}
|
|
|
|
expect(res.status).toEqual(400);
|
|
expect(res.data.type).toEqual('urn:ietf:params:acme:error:badNonce');
|
|
});
|
|
|
|
tap.test('server: finalize rejects order not in ready state', async () => {
|
|
// Create a new order but don't complete challenges
|
|
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
|
|
identifiers: [{ type: 'dns', value: 'pending.example.com' }],
|
|
});
|
|
|
|
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
|
|
commonName: 'pending.example.com',
|
|
});
|
|
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
|
|
|
const finalizeRes = await signedRequest(orderRes.data.finalize, {
|
|
csr: csrDer.toString('base64url'),
|
|
});
|
|
|
|
expect(finalizeRes.status).toEqual(403);
|
|
expect(finalizeRes.data.type).toEqual('urn:ietf:params:acme:error:orderNotReady');
|
|
});
|
|
|
|
tap.test('server: getCaCertPem returns root CA certificate', async () => {
|
|
const caPem = server.getCaCertPem();
|
|
expect(caPem).toInclude('BEGIN CERTIFICATE');
|
|
});
|
|
|
|
tap.test('server: stop ACME server', async () => {
|
|
await server.stop();
|
|
});
|
|
|
|
export default tap.start();
|