// Confirm tokens for `storebound serve --stdio` (the desktop app's sidecar protocol). // // A write method only runs for real (dryRun: false) with a token that the desktop host mints after the owner said yes // in a native dialog showing the dry run. The token is an HMAC over the method, the canonical params and an expiry, // keyed with a per-session secret the host generates and hands over in `session.hello` on stdin. Only the process that // owns the sidecar's stdin (the Rust host) knows the secret; the webview never sees it, and child commands never get it // (runner.ts strips STOREBOUND_SESSION* from their env, and the secret never is in the env to begin with). // // token = "sbt1." + expiryMs + "." + nonce + "." + base64url(HMAC-SHA256(secret, payload)) // payload = "storebound-confirm-v1\n" + method + "\n" + canonical(params) + "\n" + expiryMs + "\n" + nonce // // Tokens are single use (nonce kept until expiry), live at most MAX_TTL_MS, and are bound to the exact method and // params: another project, other arguments or another method do not verify. The Rust side (apps/desktop/src-tauri/ // src/confirm.rs) implements the same scheme; both test the shared vector in tests/serve.test.ts and tests/confirm.rs. import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; export const TOKEN_PREFIX = 'sbt1'; export const DOMAIN = 'storebound-confirm-v1'; /** A token may not be valid for longer than this (the host mints 60 s ones right after the dialog). */ export const MAX_TTL_MS = 120_000; export const MIN_SECRET_BYTES = 32; /** * Canonical JSON: object keys sorted (by UTF-16 code units, like JS sort), no whitespace, strings escaped by * JSON.stringify. Numbers must be safe integers so every language prints them the same way; undefined object * members are dropped, like JSON.stringify does. */ export function canonical(v: unknown): string { if (v === null) return 'null'; if (typeof v === 'boolean') return v ? 'true' : 'false'; if (typeof v === 'number') { if (!Number.isSafeInteger(v)) throw new Error('canonical: only safe integers are allowed as numbers'); return String(v); } if (typeof v === 'string') return JSON.stringify(v); if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`; if (typeof v === 'object') { const o = v as Record; const keys = Object.keys(o).filter(k => o[k] !== undefined).sort(); return `{${keys.map(k => `${JSON.stringify(k)}:${canonical(o[k])}`).join(',')}}`; } throw new Error(`canonical: unsupported ${typeof v}`); } const b64url = (b: Buffer): string => b.toString('base64url'); export function payload(method: string, params: unknown, expiry: number, nonce: string): string { return `${DOMAIN}\n${method}\n${canonical(params)}\n${expiry}\n${nonce}`; } export function mac(secret: Buffer, method: string, params: unknown, expiry: number, nonce: string): string { return b64url(createHmac('sha256', secret).update(payload(method, params, expiry, nonce), 'utf8').digest()); } /** Mints a token (the host does this in Rust; here for tests and tools). */ export function mint(secret: Buffer, method: string, params: unknown, o: { now?: number; ttlMs?: number; nonce?: string } = {}): string { const expiry = (o.now ?? Date.now()) + (o.ttlMs ?? 60_000); const nonce = o.nonce ?? b64url(randomBytes(16)); return `${TOKEN_PREFIX}.${expiry}.${nonce}.${mac(secret, method, params, expiry, nonce)}`; } export function parseSecret(hex: unknown): Buffer { if (typeof hex !== 'string' || !/^[0-9a-f]+$/i.test(hex) || hex.length % 2) throw new Error('secret: hex string expected'); const b = Buffer.from(hex, 'hex'); if (b.length < MIN_SECRET_BYTES) throw new Error(`secret: at least ${MIN_SECRET_BYTES} bytes`); return b; } export type VerifyError = 'malformed' | 'expired' | 'too-long' | 'replayed' | 'mismatch'; /** Verifies tokens of one session and remembers used nonces until they expire (replay protection). */ export class TokenGuard { private used = new Map(); constructor(private readonly secret: Buffer, private readonly clock: () => number = Date.now) {} verify(token: unknown, method: string, params: unknown): { ok: true } | { ok: false; error: VerifyError } { const now = this.clock(); for (const [n, exp] of this.used) if (exp <= now) this.used.delete(n); if (typeof token !== 'string') return { ok: false, error: 'malformed' }; const parts = token.split('.'); if (parts.length !== 4 || parts[0] !== TOKEN_PREFIX) return { ok: false, error: 'malformed' }; const [, exp, nonce, sig] = parts as [string, string, string, string]; if (!/^\d{1,16}$/.test(exp) || !/^[A-Za-z0-9_-]{16,64}$/.test(nonce) || !/^[A-Za-z0-9_-]{43}$/.test(sig)) return { ok: false, error: 'malformed' }; const expiry = Number(exp); let want: string; try { want = mac(this.secret, method, params, expiry, nonce); } catch { return { ok: false, error: 'malformed' }; } const a = Buffer.from(sig), b = Buffer.from(want); if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, error: 'mismatch' }; if (expiry <= now) return { ok: false, error: 'expired' }; if (expiry - now > MAX_TTL_MS) return { ok: false, error: 'too-long' }; if (this.used.has(nonce)) return { ok: false, error: 'replayed' }; this.used.set(nonce, expiry); return { ok: true }; } }