feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
153
ts_server/server.classes.jws.verifier.ts
Normal file
153
ts_server/server.classes.jws.verifier.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
||||
import type { NonceManager } from './server.classes.nonce.js';
|
||||
import type { IServerAccountStore } from './server.interfaces.js';
|
||||
|
||||
export interface IJwsVerifyResult {
|
||||
jwk: Record<string, string>;
|
||||
kid: string | null;
|
||||
thumbprint: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies JWS-signed ACME requests.
|
||||
* This is the inverse of AcmeCrypto.createJws().
|
||||
*/
|
||||
export class JwsVerifier {
|
||||
private nonceManager: NonceManager;
|
||||
private accountStore: IServerAccountStore;
|
||||
|
||||
constructor(nonceManager: NonceManager, accountStore: IServerAccountStore) {
|
||||
this.nonceManager = nonceManager;
|
||||
this.accountStore = accountStore;
|
||||
}
|
||||
|
||||
async verify(
|
||||
body: { protected: string; payload: string; signature: string },
|
||||
expectedUrl: string,
|
||||
): Promise<IJwsVerifyResult> {
|
||||
if (!body || !body.protected || body.signature === undefined) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS structure');
|
||||
}
|
||||
|
||||
// 1. Decode protected header
|
||||
const headerJson = Buffer.from(body.protected, 'base64url').toString('utf-8');
|
||||
let header: Record<string, any>;
|
||||
try {
|
||||
header = JSON.parse(headerJson);
|
||||
} catch {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS protected header');
|
||||
}
|
||||
|
||||
// 2. Validate required fields
|
||||
const { alg, nonce, url, jwk, kid } = header;
|
||||
if (!alg) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing alg in protected header');
|
||||
}
|
||||
if (!nonce) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Missing nonce in protected header');
|
||||
}
|
||||
if (!url) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing url in protected header');
|
||||
}
|
||||
|
||||
// 3. Validate URL matches
|
||||
if (url !== expectedUrl) {
|
||||
throw new AcmeServerError(
|
||||
400,
|
||||
'urn:ietf:params:acme:error:malformed',
|
||||
`URL mismatch: expected ${expectedUrl}, got ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Validate and consume nonce
|
||||
if (!this.nonceManager.consume(nonce)) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Invalid or expired nonce');
|
||||
}
|
||||
|
||||
// 5. Must have exactly one of jwk or kid
|
||||
if (jwk && kid) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid, not both');
|
||||
}
|
||||
if (!jwk && !kid) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid');
|
||||
}
|
||||
|
||||
// 6. Resolve the public key
|
||||
let resolvedJwk: Record<string, string>;
|
||||
if (jwk) {
|
||||
resolvedJwk = jwk;
|
||||
} else {
|
||||
// Look up account by kid (account URL)
|
||||
const account = await this.accountStore.getByUrl(kid);
|
||||
if (!account) {
|
||||
throw new AcmeServerError(
|
||||
400,
|
||||
'urn:ietf:params:acme:error:accountDoesNotExist',
|
||||
'Account not found for kid',
|
||||
);
|
||||
}
|
||||
resolvedJwk = account.jwk;
|
||||
}
|
||||
|
||||
// 7. Reconstruct public key and verify signature
|
||||
const publicKey = crypto.createPublicKey({ key: resolvedJwk, format: 'jwk' });
|
||||
const signingInput = `${body.protected}.${body.payload}`;
|
||||
const signatureBuffer = Buffer.from(body.signature, 'base64url');
|
||||
|
||||
const supportedAlgs = ['RS256', 'ES256', 'ES384'];
|
||||
if (!supportedAlgs.includes(alg)) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badSignatureAlgorithm', `Unsupported algorithm: ${alg}`);
|
||||
}
|
||||
|
||||
let valid: boolean;
|
||||
if (alg.startsWith('RS')) {
|
||||
valid = crypto.verify('sha256', Buffer.from(signingInput), publicKey, signatureBuffer);
|
||||
} else {
|
||||
valid = crypto.verify('sha256', Buffer.from(signingInput), { key: publicKey, dsaEncoding: 'ieee-p1363' }, signatureBuffer);
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
throw new AcmeServerError(403, 'urn:ietf:params:acme:error:unauthorized', 'Invalid JWS signature');
|
||||
}
|
||||
|
||||
// 8. Decode payload
|
||||
let payload: any;
|
||||
if (body.payload === '') {
|
||||
payload = null; // POST-as-GET
|
||||
} else {
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(body.payload, 'base64url').toString('utf-8'));
|
||||
} catch {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS payload');
|
||||
}
|
||||
}
|
||||
|
||||
const thumbprint = AcmeCrypto.getJwkThumbprint(resolvedJwk);
|
||||
|
||||
return {
|
||||
jwk: resolvedJwk,
|
||||
kid: kid || null,
|
||||
thumbprint,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple error class for ACME server errors that maps to RFC 8555 problem responses.
|
||||
*/
|
||||
export class AcmeServerError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly type: string;
|
||||
public readonly detail: string;
|
||||
|
||||
constructor(status: number, type: string, detail: string) {
|
||||
super(`${type}: ${detail}`);
|
||||
this.name = 'AcmeServerError';
|
||||
this.status = status;
|
||||
this.type = type;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user