Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion packages/sdk/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 }));
Expand Down
68 changes: 48 additions & 20 deletions packages/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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.
*
Expand Down Expand Up @@ -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,
Expand Down