154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|