Files
smartacme/ts_server/server.classes.jws.verifier.ts

154 lines
4.9 KiB
TypeScript
Raw Permalink Normal View History

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