From a972ba409f591446b1a05bd0fd335af1a35171d9 Mon Sep 17 00:00:00 2001 From: timo126 Date: Thu, 27 Aug 2026 04:56:35 +0000 Subject: [PATCH] feat(sdk): support Ed25519 signing with WebCrypto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable settlement signing in edge and browser runtimes while retaining the Node.js fallback, and document supported runtimes. Closes #101 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- packages/sdk/README.md | 2 ++ packages/sdk/index.test.ts | 17 +++++++++- packages/sdk/index.ts | 68 +++++++++++++++++++++++++++----------- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 988b61e..744c73a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -8,6 +8,8 @@ Accensa supports merchant-reported route attribution via the `/api/hook/settle` To maintain integrity, the payload is authenticated. Sellers using `@accensa/sdk` will have this handled automatically via `createSettleHook` or `attachAccensaHook`. +Signing uses WebCrypto Ed25519 when `globalThis.crypto.subtle` supports it, and falls back to Node.js `crypto` otherwise. The SDK is supported and tested on Node.js, Vercel Edge Functions, Cloudflare Workers, and Deno Deploy. Runtimes without either WebCrypto Ed25519 or Node.js crypto fail loudly rather than sending an unsigned report. + ### Signing Contract (For Non-JS Implementers) If you are integrating with Accensa from a non-JavaScript environment, you must construct and sign the settlement report yourself. diff --git a/packages/sdk/index.test.ts b/packages/sdk/index.test.ts index 5c85aa2..909d453 100644 --- a/packages/sdk/index.test.ts +++ b/packages/sdk/index.test.ts @@ -85,7 +85,8 @@ async function runHook( middleware(req, res, next); res.emit('finish'); // reportSettlement is deliberately not awaited by the middleware. - await new Promise((resolve) => setImmediate(resolve)); + await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()); + await new Promise((resolve) => setTimeout(resolve, 10)); return next; } @@ -110,6 +111,20 @@ describe('toSettleHookPayload', () => { }); describe('reportSettlement', () => { + it('reports loudly when signing is unavailable', async () => { + const onError = vi.fn(); + const fetchImpl = okFetch(); + const originalImport = globalThis.crypto; + vi.stubGlobal('crypto', { subtle: { importKey: vi.fn().mockRejectedValue(new Error('unsupported')) } }); + vi.stubGlobal('process', undefined); + vi.stubGlobal('Buffer', undefined); + await expect(reportSettlement(settlement, opts({ fetchImpl, onError }))).resolves.toBe(false); + expect(String(onError.mock.calls[0][0])).toContain('Ed25519 signing unavailable'); + expect(fetchImpl).not.toHaveBeenCalled(); + vi.stubGlobal('crypto', originalImport); + vi.unstubAllGlobals(); + }); + it('posts the signed payload to the settle endpoint', async () => { const fetchImpl = okFetch(); const result = await reportSettlement(settlement, opts({ fetchImpl })); diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index b080bc2..f14ac5a 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -78,6 +78,53 @@ export interface AccensaHookOptions { */ export const DEFAULT_TIMEOUT_MS = 5_000; +/** PKCS#8 wrapper for a raw 32-byte Ed25519 private seed (RFC 8410). */ +const ED25519_PKCS8_PREFIX = '302e020100300506032b657004220420'; + +function privateKeyPkcs8(privateKeyHex: string): ArrayBuffer { + if (!/^[0-9a-fA-F]{64}$/.test(privateKeyHex)) { + throw new Error('Ed25519 private key must be exactly 32 bytes encoded as hex'); + } + const result = new Uint8Array(48); + for (let i = 0; i < ED25519_PKCS8_PREFIX.length; i += 2) { + result[i / 2] = Number.parseInt(ED25519_PKCS8_PREFIX.slice(i, i + 2), 16); + } + for (let i = 0; i < 32; i += 1) { + result[16 + i] = Number.parseInt(privateKeyHex.slice(i * 2, i * 2 + 2), 16); + } + return result.buffer; +} + +async function signSettlementPayload(payload: string, privateKeyHex: string): Promise { + const data = new TextEncoder().encode(payload); + const pkcs8 = privateKeyPkcs8(privateKeyHex); + const subtle = globalThis.crypto?.subtle; + + if (subtle) { + try { + const key = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']); + const signature = await subtle.sign({ name: 'Ed25519' }, key, data); + return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, '0')).join(''); + } catch { + // Ed25519 is not available in every WebCrypto implementation; try Node below. + } + } + + try { + const crypto = await import('node:crypto'); + const privateKey = crypto.createPrivateKey({ + key: Buffer.from(pkcs8), + format: 'der', + type: 'pkcs8', + }); + return crypto.sign(null, Buffer.from(data), privateKey).toString('hex'); + } catch { + throw new Error( + 'Ed25519 signing unavailable: WebCrypto Ed25519 support and Node.js crypto are missing', + ); + } +} + /** * The body POSTed to `/api/hook/settle`, and the exact bytes that get signed. * @@ -153,26 +200,7 @@ export async function reportSettlement( try { const payload = JSON.stringify(body); - let signatureHex = ''; - if (typeof process !== 'undefined' && process.versions && process.versions.node) { - // Node.js environment - const crypto = await import('node:crypto'); - const keyBuffer = Buffer.from(opts.privateKeyHex, 'hex'); - const privateKey = crypto.createPrivateKey({ - key: Buffer.concat([ - Buffer.from('302e020100300506032b657004220420', 'hex'), // PKCS#8 Ed25519 header - keyBuffer, - ]), - format: 'der', - type: 'pkcs8', - }); - signatureHex = crypto.sign(null, Buffer.from(payload), privateKey).toString('hex'); - } else { - // Browser/Edge has no node:crypto. Fail loudly rather than skip signing: - // an unsigned report is rejected with 401 by the hook anyway, and a - // silent no-op here would look like a delivered report that never landed. - throw new Error('Ed25519 signing requires Node.js crypto in this version'); - } + const signatureHex = await signSettlementPayload(payload, opts.privateKeyHex); // A transient 5xx from the indexer (or a dropped connection) is retried // with exponential backoff (#123) — a 4xx, or the abort above firing,