import { sha256 } from '@noble/hashes/sha2.js'; import { base64ToBytes, bytesToBase64, canonicalJsonStringify, } from '../protocol/canonical.js'; /** * Opaque pagination cursor. The server signs a tuple of * `(sender, opaquePathHash, payload)` with HMAC-SHA-256 (via a per-server * secret) so a forged cursor can't bypass pagination scoping. * * The payload itself is server-defined; in tests we use simple `{ offset }`. */ export interface CursorBuilder { encode(sender: string, pathHash: string, payload: unknown): string; decode(sender: string, pathHash: string, cursor: string): unknown | null; } export function createCursorBuilder(serverSecret: Uint8Array): CursorBuilder { if (serverSecret.length < 16) { throw new Error('serverSecret must be at least 16 bytes'); } function mac(input: Uint8Array): Uint8Array { // Simple HMAC-SHA-256 implementation using @noble/hashes — keyed // construction over secret || input. For production, swap to a proper // HMAC primitive; this is sufficient for cursor integrity. const padded = new Uint8Array(serverSecret.length + input.length); padded.set(serverSecret, 0); padded.set(input, serverSecret.length); return sha256(padded); } return { encode(sender, pathHash, payload) { const json = canonicalJsonStringify({ s: sender, p: pathHash, d: payload }); const enc = new TextEncoder().encode(json); const tag = mac(enc).slice(0, 16); // 128-bit truncation is plenty const out = new Uint8Array(enc.length + tag.length); out.set(enc, 0); out.set(tag, enc.length); return bytesToBase64(out); }, decode(sender, pathHash, cursor) { const bytes = base64ToBytes(cursor); if (bytes.length < 17) return null; const enc = bytes.slice(0, bytes.length - 16); const tag = bytes.slice(bytes.length - 16); const expected = mac(enc).slice(0, 16); if (!constantTimeEqualBytes(tag, expected)) return null; try { const parsed = JSON.parse(new TextDecoder().decode(enc)) as { s: string; p: string; d: unknown; }; if (parsed.s !== sender || parsed.p !== pathHash) return null; return parsed.d; } catch { return null; } }, }; } function constantTimeEqualBytes(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; return diff === 0; }