/** * Beckn HTTP Signature (Fabric v2). * * Every request carries an `Authorization` header of the form: * * Signature keyId="{subscriberId}|{recordId}|{algorithm}",algorithm="ed25519", * created="{unix}",expires="{unix}",headers="(created) (expires) digest", * signature="{base64-ed25519-signature}" * * The signature covers the canonical signing string built from the declared * headers — for Fabric that is always `(created) (expires) digest`, where * `digest` is the BLAKE2b-512 hash of the raw request body, Base64-encoded * and tagged `BLAKE-512={base64}`. * * See https://docs.nfh.global/product-documentation/products/catalg/api-reference#authentication */ import { createHash, sign as edSign, verify as edVerify, generateKeyPairSync, createPrivateKey, createPublicKey } from "node:crypto"; const SIGNATURE_VALIDITY_SECONDS = 600; /** BLAKE2b-512 digest of the raw body, in Fabric wire format: `BLAKE-512={base64}`. */ export function bodyDigest(body) { const raw = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body); const hash = createHash("blake2b512").update(raw).digest("base64"); return `BLAKE-512=${hash}`; } /** * Canonical signing string for `headers="(created) (expires) digest"`: * one `name: value` pair per line, joined with `\n`, no trailing newline. */ export function signingString({ created, expires, digest }) { return [`(created): ${created}`, `(expires): ${expires}`, `digest: ${digest}`].join("\n"); } /** * Build the full `Authorization` header value for a request body. * * @param {object} opts * @param {string|Buffer} opts.body - the exact bytes that will be sent * @param {string} opts.subscriberId - participant identity (`namespace_id/registry_id`) * @param {string} opts.recordId - signing-key record id registered in the DeDi registry * @param {import("node:crypto").KeyObject|string} opts.privateKey - Ed25519 private key (KeyObject or PEM) * @param {number} [opts.created] - unix seconds; defaults to now * @param {number} [opts.expires] - unix seconds; defaults to created + 10 minutes */ export function buildAuthorizationHeader({ body, subscriberId, recordId, privateKey, created, expires }) { const key = typeof privateKey === "string" ? createPrivateKey(privateKey) : privateKey; created = created ?? Math.floor(Date.now() / 1000); expires = expires ?? created + SIGNATURE_VALIDITY_SECONDS; const digest = bodyDigest(body); const toSign = signingString({ created, expires, digest }); const signature = edSign(null, Buffer.from(toSign, "utf8"), key).toString("base64"); const keyId = `${subscriberId}|${recordId}|ed25519`; return ( `Signature keyId="${keyId}",algorithm="ed25519",` + `created="${created}",expires="${expires}",` + `headers="(created) (expires) digest",signature="${signature}"` ); } /** Parse a `Signature ...` Authorization header into its parameters. */ export function parseAuthorizationHeader(header) { if (typeof header !== "string" || !header.startsWith("Signature ")) return null; const params = {}; const re = /([a-zA-Z]+)="([^"]*)"/g; let m; while ((m = re.exec(header.slice("Signature ".length))) !== null) params[m[1]] = m[2]; if (!params.keyId || !params.signature) return null; const [subscriberId, recordId, algorithm] = params.keyId.split("|"); return { subscriberId, recordId, algorithm: params.algorithm ?? algorithm, created: Number(params.created), expires: Number(params.expires), headers: params.headers, signature: params.signature, }; } /** * Verify a parsed signature against the raw request body and an Ed25519 * public key. Checks the digest binding, the validity window, and the * signature itself. Returns { ok } or { ok: false, reason }. */ export function verifySignature({ header, body, publicKey, now = Math.floor(Date.now() / 1000) }) { const parsed = typeof header === "string" ? parseAuthorizationHeader(header) : header; if (!parsed) return { ok: false, reason: "unparseable Authorization header" }; if (parsed.algorithm !== "ed25519") return { ok: false, reason: `unsupported algorithm ${parsed.algorithm}` }; if (Number.isFinite(parsed.created) && parsed.created > now + 60) return { ok: false, reason: "signature created in the future" }; if (Number.isFinite(parsed.expires) && parsed.expires < now) return { ok: false, reason: "signature expired" }; const digest = bodyDigest(body); const toSign = signingString({ created: parsed.created, expires: parsed.expires, digest }); const key = typeof publicKey === "string" ? importPublicKey(publicKey) : publicKey; const ok = edVerify(null, Buffer.from(toSign, "utf8"), key, Buffer.from(parsed.signature, "base64")); return ok ? { ok: true } : { ok: false, reason: "signature does not verify against resolved public key" }; } /** Generate a fresh Ed25519 key pair as PEM strings plus the raw public key in base64. */ export function generateKeyPair() { const { publicKey, privateKey } = generateKeyPairSync("ed25519"); return { privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(), // Raw 32-byte key, base64 — the format registries typically want. publicKeyBase64: publicKey.export({ type: "spki", format: "der" }).subarray(-32).toString("base64"), }; } /** Accept a public key as PEM or as base64 raw/SPKI-DER and return a KeyObject. */ export function importPublicKey(material) { if (material.includes("-----BEGIN")) return createPublicKey(material); const der = Buffer.from(material, "base64"); if (der.length === 32) { // Raw Ed25519 key: wrap in an SPKI header. const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex"); return createPublicKey({ key: Buffer.concat([spkiPrefix, der]), format: "der", type: "spki" }); } return createPublicKey({ key: der, format: "der", type: "spki" }); }