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; 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 { 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; 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; 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; } }