storebound/cli/lib/confirm-token.ts
CRT d74d99bcc9 serve --stdio: desktop JSON-RPC profile on the MCP core, confirm tokens, --json results, sidecar builds
- cli/serve.ts: session.hello with a per-session secret, typed methods per tool, project.inspect,
  portfolio.status over many project folders, progress notifications, cancellation
- cli/lib/confirm-token.ts: single-use HMAC tokens bound to method + canonical params + expiry;
  writes are dry runs without one; no submit / release / publish method exists
- cli/lib/runner.ts: shared child runner for mcp and serve, works from a compiled binary
- --json on check, testflight / ppo status, next-build, next-version-code, analytics compare,
  prices import; inspect command; confirm() always answers no under serve
- tools/build-sidecars.ts: bun build --compile for five targets, ad-hoc re-sign on macOS, path leak check
- tests/serve.test.ts: methods, token binding / expiry / replay, shared vector, stdio and compiled smoke
2026-10-11 01:57:33 +08:00

95 lines
5.3 KiB
TypeScript

// 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<string, unknown>;
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<string, number>();
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 };
}
}