feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
336
test/test.acme-server.node+bun+deno.ts
Normal file
336
test/test.acme-server.node+bun+deno.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user