- 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
95 lines
5.3 KiB
TypeScript
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 };
|
|
}
|
|
}
|