/** * Neo Wallet API — signing helpers. * * Copy this file into your integration. It depends on nothing but `node:crypto` (Node ≥ 18) and * implements, byte for byte, the three things an integration has to compute: * * - the request signature — Authentication guide * - the transaction-binding hash — Customer authentication guide * - the webhook signature check — Webhooks guide * * `test-vectors.json` pins the expected outputs. Neo Wallet tests this file against the API's * own signature checks on every release, so it cannot drift from what the API accepts. */ import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual, type KeyObject, } from "node:crypto"; /** PKCS#8 DER header for an Ed25519 private key; append the 32-byte seed. */ const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); export interface NeoWalletCredentials { /** `X-Api-Key` — the `nwak_…` key from the client portal's API credentials page. */ readonly apiKey: string; /** `X-Key-Id` — the `kid` registered for your Ed25519 public key. */ readonly keyId: string; /** From {@link loadSigningKey}. */ readonly privateKey: KeyObject; } /** * Loads an Ed25519 private key from either a PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`, what * `openssl genpkey -algorithm ed25519` hands you) or a base64 * 32-byte seed (what `./start.sh credentials` prints for the demo client). */ export function loadSigningKey(pemOrSeedBase64: string): KeyObject { const value = pemOrSeedBase64.trim(); if (value.includes("BEGIN PRIVATE KEY")) { const key = createPrivateKey(value); if (key.asymmetricKeyType !== "ed25519") throw new Error("signing key is not Ed25519"); return key; } const seed = Buffer.from(value, "base64"); if (seed.length !== 32) throw new Error("an Ed25519 seed is 32 bytes (base64)"); return createPrivateKey({ key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]), format: "der", type: "pkcs8", }); } /** Lower-case hex SHA-256 of the exact body bytes; no body hashes as the empty string. */ export function bodySha256Hex(body?: string | Buffer): string { return createHash("sha256") .update(body ?? "") .digest("hex"); } /** `METHOD \n PATH+QUERY \n X-Timestamp \n sha256hex(body)` — no trailing newline. */ export function canonicalRequest( method: string, pathWithQuery: string, timestamp: string, body?: string | Buffer, ): string { return [method.toUpperCase(), pathWithQuery, timestamp, bodySha256Hex(body)].join("\n"); } export interface SignRequestInput { readonly method: string; /** The request target exactly as sent, including `/api/v1` and any query string. */ readonly pathWithQuery: string; /** The exact bytes you will send. Serialise JSON once and send that same string. */ readonly body?: string | Buffer; /** Unix epoch seconds. Defaults to now; pass one only for tests. */ readonly timestamp?: string; } /** The four authentication headers for one request. A signature is single-use (replay window). */ export function signRequest(credentials: NeoWalletCredentials, input: SignRequestInput) { const timestamp = input.timestamp ?? String(Math.floor(Date.now() / 1000)); const canonical = canonicalRequest(input.method, input.pathWithQuery, timestamp, input.body); const signature = sign(null, Buffer.from(canonical, "utf8"), credentials.privateKey); return { "X-Api-Key": credentials.apiKey, "X-Key-Id": credentials.keyId, "X-Timestamp": timestamp, "X-Signature": signature.toString("base64"), }; } const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; /** A ULID — the preferred `X-Request-Id`, and a good `Idempotency-Key`. */ export function ulid(now: number = Date.now()): string { let time = ""; let ms = now; for (let i = 0; i < 10; i++) { time = CROCKFORD[ms % 32] + time; ms = Math.floor(ms / 32); } const random = [...randomBytes(16)].map((byte) => CROCKFORD[byte % 32]).join(""); return time + random; } export interface NeoWalletRequestInput { readonly method: string; readonly pathWithQuery: string; /** A JSON-serialisable body. Serialised once here; the signature covers exactly those bytes. */ readonly json?: unknown; /** * Required on writes. Reuse the **same** key when retrying the same logical request after a * timeout or 5xx — a new key on retry is how a payment is made twice. */ readonly idempotencyKey?: string; } /** * Everything `fetch` needs for one attempt: signed headers, a fresh `X-Request-Id`, and on a * write the `Idempotency-Key` and JSON content type. Build it again for every retry. */ export function buildRequest(credentials: NeoWalletCredentials, input: NeoWalletRequestInput) { const method = input.method.toUpperCase(); const body = input.json === undefined ? undefined : JSON.stringify(input.json); const isWrite = method !== "GET" && method !== "HEAD"; if (isWrite && !input.idempotencyKey) { throw new Error("writes need an Idempotency-Key"); } const headers: Record = { ...signRequest(credentials, { method, pathWithQuery: input.pathWithQuery, body }), "X-Request-Id": ulid(), }; if (body !== undefined) headers["Content-Type"] = "application/json"; if (isWrite) headers["Idempotency-Key"] = input.idempotencyKey!; return { method, headers, body }; } export interface TransactionBindingInput { readonly customerUid: string; readonly walletUid: string; /** Integer minor units, e.g. `750000` for ETB 7,500.00. */ readonly amountMinor: number | bigint; readonly currency: string; /** `destinationIdentifierUid` for a payout, `mandateUid` for a debit, omitted otherwise. */ readonly target?: string; readonly clientReference?: string; } /** The binding template; `transactionBinding.canonical` must be exactly this string. */ export const TRANSACTION_BINDING_CANONICAL = "customerUid|walletUid|amountMinor|currency|destinationIdentifierUid|clientReference"; /** `assertion.transactionBinding` for the request you are about to send. */ export function transactionBinding(input: TransactionBindingInput) { const canonical = [ input.customerUid, input.walletUid, input.amountMinor.toString(), input.currency, input.target ?? "", input.clientReference ?? "", ].join("|"); return { hash: createHash("sha256").update(canonical, "utf8").digest("hex"), algorithm: "sha256" as const, canonical: TRANSACTION_BINDING_CANONICAL, }; } /** * Webhook signature check. Pass the **raw** body bytes, the request headers (any case) and every * secret you currently hold by `kid` — during a rotation overlap both versions sign deliveries. */ export function verifyWebhook( rawBody: Buffer, headers: Record, secretsByKid: Readonly>, options: { toleranceSeconds?: number; now?: number } = {}, ): boolean { const header = (name: string) => { const key = Object.keys(headers).find((k) => k.toLowerCase() === name); const value = key === undefined ? undefined : headers[key]; return (Array.isArray(value) ? value[0] : value) ?? ""; }; const secret = secretsByKid[header("x-webhook-key-id")]; const timestamp = header("x-webhook-timestamp"); const presented = header("x-webhook-signature").trim().toLowerCase(); if (!secret || !/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(presented)) return false; const expected = createHmac("sha256", secret) .update(Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody])) .digest(); if (!timingSafeEqual(Buffer.from(presented, "hex"), expected)) return false; const nowSeconds = Math.floor((options.now ?? Date.now()) / 1000); return Math.abs(nowSeconds - Number(timestamp)) <= (options.toleranceSeconds ?? 300); }