From 1bd7037a6df9ddc8373ce938f3a2dc00bbe6bfc6 Mon Sep 17 00:00:00 2001 From: Sterister Date: Sat, 9 May 2026 17:09:59 +0200 Subject: [PATCH] release(v4.10.0): cross-host approval routing primitives in @shade/sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on V4.9's encrypted profile blob: ships the canonical profile-blob schema (hosts/clients/trustedApproverFingerprints) and the build/sign/verify trio for proxy-approval frames. Headless servers can now route a `linkRequest` to a trusted-approver phone, verify the phone's Ed25519 signature against the fresh profile blob, and complete pairing without a GUI host being available. Length-prefixed binary signing payload so any platform (Kotlin, Swift, Go) can produce byte-identical signing input from test vectors. No relay or transport changes — entirely SDK-level. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 76 ++ packages/shade-cli/package.json | 2 +- packages/shade-core/package.json | 2 +- packages/shade-crypto-web/package.json | 2 +- packages/shade-dashboard/package.json | 2 +- packages/shade-files/package.json | 2 +- packages/shade-inbox-server/package.json | 2 +- packages/shade-inbox/package.json | 2 +- packages/shade-key-transparency/package.json | 2 +- packages/shade-keychain/package.json | 2 +- packages/shade-observability/package.json | 2 +- packages/shade-observer/package.json | 2 +- packages/shade-proto/package.json | 2 +- packages/shade-recovery/package.json | 2 +- packages/shade-sdk/package.json | 2 +- packages/shade-sdk/src/approval.ts | 792 ++++++++++++++++++ packages/shade-sdk/src/index.ts | 34 + packages/shade-sdk/tests/approval.test.ts | 552 ++++++++++++ packages/shade-server/package.json | 2 +- packages/shade-storage-encrypted/package.json | 2 +- packages/shade-storage-indexeddb/package.json | 2 +- packages/shade-storage-postgres/package.json | 2 +- packages/shade-storage-sqlite/package.json | 2 +- packages/shade-streams/package.json | 2 +- packages/shade-transfer/package.json | 2 +- packages/shade-transport-bridge/package.json | 2 +- packages/shade-transport-webrtc/package.json | 2 +- packages/shade-transport/package.json | 2 +- packages/shade-widgets/package.json | 2 +- 29 files changed, 1479 insertions(+), 25 deletions(-) create mode 100644 packages/shade-sdk/src/approval.ts create mode 100644 packages/shade-sdk/tests/approval.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f0770..1ae6a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,82 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.10.0] — 2026-05-09 — cross-host approval routing primitives + +Prism filed a follow-up feature request +(`cross-host-approval-routing.md`) building on V4.9: now that the +encrypted profile blob is shipped, headless servers and away-from-PC +scenarios still can't approve a `linkRequest` from a new device +because there's no GUI to pop a dialog. Solution: a *trusted-approver* +phone signs an Ed25519 approval that any host can verify against the +freshest profile blob, even if the host has never spoken to the phone +before (X3DH-on-first-send via the existing `Shade.send` handles +session bootstrap). + +The FR's questions (1) "is X3DH-on-first-send supported?" and +(5) "is there a broadcast helper to N addresses with X3DH-on-first?" +are both answered by *yes, `Shade.send` already does this per call* +— no new relay primitives needed. What this release ships is the +*shape* every Shade app would otherwise reinvent: a canonical +profile-blob schema and the build/sign/verify trio for proxy approvals. + +**SDK (`@shade/sdk`)** + +- Canonical profile-blob schema: `CanonicalProfileBlob` with + `hosts[]`, `clients[]`, and a denormalized + `trustedApproverFingerprints[]`. `parseCanonicalProfile` / + `serializeCanonicalProfile` round-trip JSON; mutators + (`upsertHost`, `upsertClient`, `setTrustedApprover`, + `removeClient`, ...) are immutable and re-derive the + denormalized list on every change so it can't drift. +- `ProfileClientEntry` stores both `identityPublicKey` (32-byte hex, + used by `verifyProxyApproval`) and `identityFingerprint` (safety- + number for display). The FR sketched only the fingerprint; storing + the public key in-band drops the prekey-server dependency at + verify-time and lets any host check signatures from a fresh + profile read alone. +- Approval frames: `ApprovalRequestFrame` (`kind: 'approvalNeeded'`) + and `ProxyApprovalFrame` (`kind: 'linkApproveByProxy'`). + `buildApprovalRequest` mints a 128-bit hex `requestId` and a + configurable expiry (default 5 min). +- `signProxyApproval` / `verifyProxyApproval` use a length-prefixed + binary signing payload (`canonicalApprovalSigningBytes`) that binds + domain, requestId, host fingerprint, requesting-device fingerprint, + and decision. Length-prefixed (u16 BE) so any platform — Kotlin, + Swift, Go — can produce byte-identical bytes from test vectors + without a JSON canonicalizer. +- Domain separator: `DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1'`. + Apps with their own canonical name (Prism uses + `prism-link-approve-v1`) override via `domain`. The frame carries + the domain so a verifier rejects mismatch before signature check. +- `verifyProxyApproval` returns a tagged result instead of throwing. + Reasons: `request-id-mismatch`, `domain-mismatch`, + `unknown-approver`, `not-trusted`, `bad-signature`, `expired`. + Hosts log the reason and decide what to surface to the user. +- `isTrustedApprover` cross-checks the per-client `trustedApprover` + flag AND the denormalized `trustedApproverFingerprints[]`. Both + must agree — defends against a partially-written blob. + +**Threat model — what's new** + +- Compromised relay still can't read or forge approvals. The + approval signature is verified against the approver's long-term + Ed25519 identity key stored in the profile blob, which the relay + can't decrypt or rewrite (profile-blob TOFU on owner-pubkey from + V4.9 still applies). +- Revocation TOCTOU: hosts MUST refetch the profile blob fresh + before honoring `linkApproveByProxy`. The `verifyProxyApproval` + signature accepts the blob as a parameter — caller controls + freshness. One extra `Profile.get` RTT per approval. +- The signature is belt-and-suspenders on top of the bilateral E2EE + that delivers the frame. The E2EE channel already authenticates + the sender's session, but the long-term identity binding means an + approval is verifiable independently of session state. + +**No relay or transport changes**. All app-level. Hosts persist their +own pending-`requestId` set for replay protection; that's app state +the SDK doesn't need to track. + ## [4.9.0] — 2026-05-09 — relay-side encrypted blob primitive + SDK `Profile` namespace Prism filed a feature request diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 74fb845..16d7950 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index 5a3865d..74083a9 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index c8b8615..5ae22c5 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index e6f99c3..f57984b 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 34e3b86..78cd140 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index 46cb1ba..52b7e71 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index 9bbf7b4..0fd29a5 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index 1d2a927..dabdb7e 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index 4b29a24..8247661 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index c4bc624..6fd171c 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index 637934e..1a0002d 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 3d23ed5..86081f2 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index 034f62d..cb89a38 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index aafe49a..3f07e6d 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/src/approval.ts b/packages/shade-sdk/src/approval.ts new file mode 100644 index 0000000..8329b6b --- /dev/null +++ b/packages/shade-sdk/src/approval.ts @@ -0,0 +1,792 @@ +import type { CryptoProvider } from '@shade/core'; +import { ValidationError } from '@shade/core'; + +/** + * V4.10 — cross-host approval routing. + * + * Two pieces, both app-level (no relay changes): + * + * 1. A canonical schema for the V4.9 profile blob. The blob is opaque to + * Shade, but Prism's `cross-host-approval-routing` FR pointed out that + * every Shade app that supports credential-driven device linking will + * end up reinventing the same `hosts[]` / `clients[]` / + * `trustedApproverFingerprints[]` shape. We bless one here so apps can + * interop and so the proxy-approval verify path has a stable place to + * look up trusted-approver public keys. + * + * 2. Build / sign / verify helpers for `approvalNeeded` and + * `linkApproveByProxy` frames. The frames themselves are app-defined + * payloads sent over the existing `Shade.send` / `Shade.receive` + * bilateral E2EE channel — Shade just exposes the signing-payload + * canonicalization and Ed25519 plumbing so a fingerprint+signature + * bound to a long-term identity key can be verified on any host that + * has a fresh copy of the user's profile blob. + * + * The proxy-approval signature is *belt-and-suspenders* on top of the + * E2EE that delivered the frame: the E2EE channel already authenticates + * the sender's session, but the signature ties the approval explicitly + * to the approver's long-term identity key. That makes the approval + * verifiable independently of session state — useful if a host + * receives a forwarded approval without a prior session, and necessary + * for replay-resistance in the face of a compromised relay that + * reorders bilateral sends. + * + * Headless servers without a GUI use this with the existing X3DH-on- + * first-send behavior of `Shade.send` to ask any trusted-approver + * client (typically a phone) to approve a `linkRequest` from a new + * device. The approver phone biometrically gates a long-term Ed25519 + * sign, ships the frame back, and the server runs `verifyProxyApproval` + * against the freshest profile blob before completing pairing. + */ + +// ─── Canonical profile-blob schema ───────────────────────────── + +/** + * A host: a device that *receives* `linkRequest` frames from new + * devices and runs the pairing handshake. Typically a desktop, server, + * or always-on laptop. Hosts have no special crypto status — they're + * just where the user's data lives. + */ +export interface ProfileHostEntry { + /** Shade address (`device:...`). */ + address: string; + /** Human-friendly name (e.g. "PC", "Server"). */ + name: string; + /** Open enum: `'desktop' | 'server' | 'laptop' | ...`. */ + kind: string; + /** Wall-clock ms when this host was added to the profile. */ + addedAt: number; +} + +/** + * A client: a device that *initiates* link/approval flows and may + * proxy-approve link-requests on behalf of the account when + * `trustedApprover === true`. Typically a phone or tablet. + * + * `identityPublicKey` is the 32-byte Ed25519 long-term identity key. + * `identityFingerprint` is the human-readable safety-number derived + * from it (the same value `Shade.getFingerprintFor(address)` returns). + * Both are stored: the public key is what `verifyProxyApproval` uses + * to check signatures; the fingerprint is what UIs show users for + * out-of-band verification. + */ +export interface ProfileClientEntry { + /** Shade address (`device:...`). */ + address: string; + /** + * 32-byte Ed25519 long-term identity public key, lowercase hex + * (64 chars). This is the key that anchors the client's + * prekey-bundle and that proxy-approvals are signed with. + */ + identityPublicKey: string; + /** + * Safety-number-style fingerprint of the identity key, exactly the + * format `computeFingerprint` returns. Stored for fast denormalized + * lookups and so UIs don't have to recompute it. + */ + identityFingerprint: string; + /** Human-friendly name (e.g. "iPhone 15"). */ + name: string; + /** Open enum: `'mobile' | 'tablet' | 'browser' | ...`. */ + kind: string; + /** Wall-clock ms when this client was added to the profile. */ + addedAt: number; + /** + * When true, this client is allowed to proxy-approve `linkRequest`s + * that arrive at any host. Toggled via the workstation's + * Settings → Devices UI. Hosts MUST verify against the freshest + * profile blob before honoring an approval (to close the + * revocation TOCTOU window — see FR §5). + */ + trustedApprover?: boolean; +} + +export interface CanonicalProfileBlob { + /** Schema version. Bump when the shape changes incompatibly. */ + version: 1; + hosts: ProfileHostEntry[]; + clients: ProfileClientEntry[]; + /** + * Denormalized list of `clients[].identityFingerprint` values where + * `trustedApprover === true`. Hosts use this for the fast-path + * "is X allowed to approve?" check; the authoritative source is + * still the per-client `trustedApprover` flag. + */ + trustedApproverFingerprints: string[]; + /** Wall-clock ms of the last write that produced this blob. */ + updatedAt: number; + /** + * Optional hex-encoded pubkey that wrote this blob. Mirrors the + * profile-storage owner-pubkey but kept in-band so apps that need + * to display "last edited by X" don't have to round-trip the + * relay. Not used for verification — the relay's TOFU on + * owner-pubkey is the authoritative auth boundary. + */ + signedBy?: string; +} + +const TEXT = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + +/** Build a fresh empty profile blob (timestamp = `now ?? Date.now()`). */ +export function emptyCanonicalProfile(now?: number): CanonicalProfileBlob { + return { + version: 1, + hosts: [], + clients: [], + trustedApproverFingerprints: [], + updatedAt: now ?? Date.now(), + }; +} + +/** + * Decode a profile-blob plaintext (the `plaintext` of a + * `ProfileGetResult`) into the canonical shape. Throws + * `ValidationError` on malformed JSON or wrong shape. + * + * Forward-compatibility: unknown top-level fields are preserved so a + * device on an older version can round-trip a blob written by a + * newer device without losing data. Unknown fields inside `hosts[]` / + * `clients[]` entries are also preserved. + */ +export function parseCanonicalProfile( + plaintext: Uint8Array | string, +): CanonicalProfileBlob { + const text = + typeof plaintext === 'string' ? plaintext : TEXT_DECODER.decode(plaintext); + let json: unknown; + try { + json = JSON.parse(text); + } catch (err) { + throw new ValidationError( + `profile blob is not valid JSON: ${(err as Error).message}`, + ); + } + if (!json || typeof json !== 'object' || Array.isArray(json)) { + throw new ValidationError('profile blob must be a JSON object'); + } + const obj = json as Record; + if (obj.version !== 1) { + throw new ValidationError( + `unsupported profile blob version: ${String(obj.version)}`, + ); + } + const hosts = validateArray(obj.hosts, 'hosts', validateHostEntry); + const clients = validateArray(obj.clients, 'clients', validateClientEntry); + const tApp = obj.trustedApproverFingerprints; + const trustedApproverFingerprints = Array.isArray(tApp) + ? tApp.map((v, i) => { + if (typeof v !== 'string') { + throw new ValidationError( + `trustedApproverFingerprints[${i}] must be a string`, + ); + } + return v; + }) + : []; + const updatedAt = + typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) + ? obj.updatedAt + : 0; + + const out: CanonicalProfileBlob = { + version: 1, + hosts, + clients, + trustedApproverFingerprints, + updatedAt, + }; + if (typeof obj.signedBy === 'string') out.signedBy = obj.signedBy; + return out; +} + +/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */ +export function serializeCanonicalProfile( + blob: CanonicalProfileBlob, +): Uint8Array { + return TEXT.encode(JSON.stringify(blob)); +} + +function validateArray( + v: unknown, + field: string, + validate: (entry: unknown, index: number, field: string) => T, +): T[] { + if (v === undefined) return []; + if (!Array.isArray(v)) { + throw new ValidationError(`${field} must be an array`); + } + return v.map((entry, i) => validate(entry, i, field)); +} + +function validateHostEntry( + entry: unknown, + index: number, + field: string, +): ProfileHostEntry { + if (!entry || typeof entry !== 'object') { + throw new ValidationError(`${field}[${index}] must be an object`); + } + const e = entry as Record; + return { + address: requireString(e.address, `${field}[${index}].address`), + name: requireString(e.name, `${field}[${index}].name`), + kind: requireString(e.kind, `${field}[${index}].kind`), + addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`), + }; +} + +function validateClientEntry( + entry: unknown, + index: number, + field: string, +): ProfileClientEntry { + if (!entry || typeof entry !== 'object') { + throw new ValidationError(`${field}[${index}] must be an object`); + } + const e = entry as Record; + const identityPublicKey = requireString( + e.identityPublicKey, + `${field}[${index}].identityPublicKey`, + ); + if (!/^[0-9a-f]{64}$/.test(identityPublicKey)) { + throw new ValidationError( + `${field}[${index}].identityPublicKey must be 64 lowercase hex chars`, + ); + } + const out: ProfileClientEntry = { + address: requireString(e.address, `${field}[${index}].address`), + identityPublicKey, + identityFingerprint: requireString( + e.identityFingerprint, + `${field}[${index}].identityFingerprint`, + ), + name: requireString(e.name, `${field}[${index}].name`), + kind: requireString(e.kind, `${field}[${index}].kind`), + addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`), + }; + if (typeof e.trustedApprover === 'boolean') { + out.trustedApprover = e.trustedApprover; + } + return out; +} + +function requireString(v: unknown, field: string): string { + if (typeof v !== 'string') { + throw new ValidationError(`${field} must be a string`); + } + return v; +} + +function requireNumber(v: unknown, field: string): number { + if (typeof v !== 'number' || !Number.isFinite(v)) { + throw new ValidationError(`${field} must be a finite number`); + } + return v; +} + +// ─── Mutators (immutable; return new blob, never mutate input) ── + +/** + * Insert or replace a host entry by address. Any existing host with + * the same address is overwritten. The output's `updatedAt` is set to + * `now ?? Date.now()` so callers don't have to remember to bump it. + */ +export function upsertHost( + blob: CanonicalProfileBlob, + host: ProfileHostEntry, + now?: number, +): CanonicalProfileBlob { + const hosts = blob.hosts.filter((h) => h.address !== host.address); + hosts.push(host); + return { ...blob, hosts, updatedAt: now ?? Date.now() }; +} + +/** Remove the host with the given address, if any. */ +export function removeHost( + blob: CanonicalProfileBlob, + address: string, + now?: number, +): CanonicalProfileBlob { + const hosts = blob.hosts.filter((h) => h.address !== address); + if (hosts.length === blob.hosts.length) return blob; + return { ...blob, hosts, updatedAt: now ?? Date.now() }; +} + +/** + * Insert or replace a client entry by `identityFingerprint` (the + * stable cryptographic identifier). Address can change without + * losing the trust record — e.g. if a phone re-pairs to a new device + * row but keeps its identity key — but a new `identityFingerprint` + * is treated as a new client. + * + * Re-derives `trustedApproverFingerprints` from the resulting + * `clients[]` so the denormalized list never drifts. + */ +export function upsertClient( + blob: CanonicalProfileBlob, + client: ProfileClientEntry, + now?: number, +): CanonicalProfileBlob { + const clients = blob.clients.filter( + (c) => c.identityFingerprint !== client.identityFingerprint, + ); + clients.push(client); + return { + ...blob, + clients, + trustedApproverFingerprints: deriveTrustedApprovers(clients), + updatedAt: now ?? Date.now(), + }; +} + +/** Remove the client with the given `identityFingerprint`, if any. */ +export function removeClient( + blob: CanonicalProfileBlob, + identityFingerprint: string, + now?: number, +): CanonicalProfileBlob { + const clients = blob.clients.filter( + (c) => c.identityFingerprint !== identityFingerprint, + ); + if (clients.length === blob.clients.length) return blob; + return { + ...blob, + clients, + trustedApproverFingerprints: deriveTrustedApprovers(clients), + updatedAt: now ?? Date.now(), + }; +} + +/** + * Toggle the `trustedApprover` flag on a client by fingerprint. + * Returns the input unchanged if the fingerprint isn't found. + */ +export function setTrustedApprover( + blob: CanonicalProfileBlob, + identityFingerprint: string, + trusted: boolean, + now?: number, +): CanonicalProfileBlob { + let touched = false; + const clients = blob.clients.map((c) => { + if (c.identityFingerprint !== identityFingerprint) return c; + if ((c.trustedApprover ?? false) === trusted) return c; + touched = true; + const next: ProfileClientEntry = { ...c }; + if (trusted) next.trustedApprover = true; + else delete next.trustedApprover; + return next; + }); + if (!touched) return blob; + return { + ...blob, + clients, + trustedApproverFingerprints: deriveTrustedApprovers(clients), + updatedAt: now ?? Date.now(), + }; +} + +/** + * True if the given fingerprint resolves to a client whose + * `trustedApprover` flag is set. Cross-checks both `clients[]` and + * the denormalized `trustedApproverFingerprints[]` — both must agree + * to count as trusted. + */ +export function isTrustedApprover( + blob: CanonicalProfileBlob, + identityFingerprint: string, +): boolean { + if (!blob.trustedApproverFingerprints.includes(identityFingerprint)) { + return false; + } + const c = findClientByFingerprint(blob, identityFingerprint); + return c?.trustedApprover === true; +} + +export function findClientByFingerprint( + blob: CanonicalProfileBlob, + identityFingerprint: string, +): ProfileClientEntry | null { + return ( + blob.clients.find((c) => c.identityFingerprint === identityFingerprint) ?? + null + ); +} + +export function findClientByAddress( + blob: CanonicalProfileBlob, + address: string, +): ProfileClientEntry | null { + return blob.clients.find((c) => c.address === address) ?? null; +} + +function deriveTrustedApprovers(clients: ProfileClientEntry[]): string[] { + return clients + .filter((c) => c.trustedApprover === true) + .map((c) => c.identityFingerprint); +} + +// ─── Approval frames ─────────────────────────────────────────── + +/** + * Default domain separator for the proxy-approval signing payload. + * Apps with their own canonical name (e.g. Prism) MAY override this + * via the `domain` option, but they must use the SAME value on both + * the signing and verifying side. The frame itself carries the + * domain so a verifier can detect mismatch and reject. + */ +export const DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1'; + +/** ms-since-epoch defaults: build = 5 minutes from now. */ +const DEFAULT_EXPIRES_IN_MS = 5 * 60 * 1000; + +export interface ApprovalRequestingDevice { + /** Safety-number fingerprint of the requesting device's identity. */ + fingerprint: string; + /** Optional human label (e.g. `"cafe-laptop"`). */ + deviceName?: string; + /** Optional `User-Agent`-like hint for display in the approve modal. */ + userAgent?: string; + /** Optional best-effort source IP for display (NOT authenticated). */ + ipHint?: string; + /** Wall-clock ms when the host received the original linkRequest. */ + receivedAt: number; +} + +export interface ApprovalRequestFrame { + kind: 'approvalNeeded'; + /** + * 128-bit random hex (32 chars) — host-generated, used as the + * idempotency key for the approval. The verifier matches the + * `linkApproveByProxy.requestId` against this exact value. + */ + requestId: string; + /** Shade address of the host that received the original linkRequest. */ + hostAddress: string; + /** Identity fingerprint of the host (the same value the approver UI shows). */ + hostFingerprint: string; + requestingDevice: ApprovalRequestingDevice; + /** + * Wall-clock ms after which the host won't accept a proxy-approval + * for this `requestId`. The approver SHOULD also reject locally if + * the user takes too long to respond. + */ + expiresAt: number; + /** Domain separator the host expects this approval to be signed under. */ + domain: string; +} + +export interface ProxyApprovalFrame { + kind: 'linkApproveByProxy'; + /** Echoed from the matching `ApprovalRequestFrame.requestId`. */ + requestId: string; + /** `'approve'` or `'reject'`. The verifier checks against this exactly. */ + decision: 'approve' | 'reject'; + /** + * Identity fingerprint of the approving client. MUST match an entry + * in the host's profile-blob `clients[]` whose `trustedApprover` + * flag is set. + */ + approverFingerprint: string; + /** + * Ed25519 signature over the canonical signing payload, lowercase + * hex (128 chars). See `canonicalApprovalSigningBytes` for the + * exact byte layout. + */ + signature: string; + /** Domain separator used to produce the signature. */ + domain: string; +} + +export interface BuildApprovalRequestOptions { + hostAddress: string; + hostFingerprint: string; + requestingDevice: Omit & { + receivedAt?: number; + }; + /** + * ms TTL after which the host won't honor a proxy-approval for + * this request. Default 5 minutes — long enough for the user to + * fish their phone out, short enough to bound replay. + */ + expiresInMs?: number; + /** + * Source of randomness for the `requestId`. Pass the same + * `CryptoProvider` you use elsewhere in the SDK (typically a + * `SubtleCryptoProvider`) — it satisfies the `randomBytes` shape. + */ + crypto: { randomBytes(n: number): Uint8Array }; + /** Domain separator. Default: `shade-link-approve-v1`. */ + domain?: string; + /** Override `Date.now()` (tests). */ + now?: () => number; +} + +/** + * Build a fresh `approvalNeeded` frame with a 128-bit random + * `requestId`. The host then ships this to each trusted-approver + * client via `Shade.send`. Hosts SHOULD persist `(requestId, expiresAt, + * requestingDevice.fingerprint)` somewhere durable so they can match + * up the eventual `linkApproveByProxy` reply. + */ +export function buildApprovalRequest( + options: BuildApprovalRequestOptions, +): ApprovalRequestFrame { + const now = (options.now ?? Date.now)(); + const requestId = bytesToHex(options.crypto.randomBytes(16)); + return { + kind: 'approvalNeeded', + requestId, + hostAddress: options.hostAddress, + hostFingerprint: options.hostFingerprint, + requestingDevice: { + fingerprint: options.requestingDevice.fingerprint, + ...(options.requestingDevice.deviceName !== undefined + ? { deviceName: options.requestingDevice.deviceName } + : {}), + ...(options.requestingDevice.userAgent !== undefined + ? { userAgent: options.requestingDevice.userAgent } + : {}), + ...(options.requestingDevice.ipHint !== undefined + ? { ipHint: options.requestingDevice.ipHint } + : {}), + receivedAt: options.requestingDevice.receivedAt ?? now, + }, + expiresAt: now + (options.expiresInMs ?? DEFAULT_EXPIRES_IN_MS), + domain: options.domain ?? DEFAULT_APPROVAL_DOMAIN, + }; +} + +export interface SignProxyApprovalOptions { + /** The frame the host sent. The verifier rebuilds the signing payload from it. */ + request: ApprovalRequestFrame; + decision: 'approve' | 'reject'; + /** + * The approving client's identity fingerprint. Must match the + * `clients[]` entry the host expects to find as a trusted approver. + */ + approverFingerprint: string; + /** + * 32-byte Ed25519 *seed* — `crypto.sign(seed, msg)` works directly + * (the noble convention `@shade/crypto-web` uses). + */ + approverSigningKey: Uint8Array; + /** CryptoProvider for `sign`. */ + crypto: Pick; +} + +/** + * Build a `linkApproveByProxy` frame signed with the approver's + * long-term Ed25519 identity key. The signing payload is + * domain-separated and binds together every field that protects + * against cross-frame replay (see `canonicalApprovalSigningBytes`). + */ +export async function signProxyApproval( + options: SignProxyApprovalOptions, +): Promise { + if (options.approverSigningKey.length !== 32) { + throw new ValidationError('approverSigningKey must be 32 bytes (Ed25519 seed)'); + } + if (options.decision !== 'approve' && options.decision !== 'reject') { + throw new ValidationError(`decision must be 'approve' or 'reject'`); + } + const payload = canonicalApprovalSigningBytes({ + domain: options.request.domain, + requestId: options.request.requestId, + hostFingerprint: options.request.hostFingerprint, + requestingDeviceFingerprint: options.request.requestingDevice.fingerprint, + decision: options.decision, + }); + const sig = await options.crypto.sign(options.approverSigningKey, payload); + return { + kind: 'linkApproveByProxy', + requestId: options.request.requestId, + decision: options.decision, + approverFingerprint: options.approverFingerprint, + signature: bytesToHex(sig), + domain: options.request.domain, + }; +} + +export interface VerifyProxyApprovalOptions { + /** The original `approvalNeeded` frame the host sent (replay-binding). */ + request: ApprovalRequestFrame; + /** The `linkApproveByProxy` frame received from the approver. */ + approval: ProxyApprovalFrame; + /** + * Profile blob to verify against. The approver must resolve to a + * `clients[]` entry whose `trustedApprover` flag is set. + * Hosts MUST refetch the blob fresh before verifying — see FR §5 + * for the revocation TOCTOU rationale. + */ + profile: CanonicalProfileBlob; + /** CryptoProvider for `verify`. */ + crypto: Pick; + /** Override `Date.now()` (tests). */ + now?: () => number; +} + +export type VerifyProxyApprovalReason = + | 'request-id-mismatch' + | 'domain-mismatch' + | 'unknown-approver' + | 'not-trusted' + | 'bad-signature' + | 'expired'; + +export type VerifyProxyApprovalResult = + | { ok: true; approver: ProfileClientEntry } + | { ok: false; reason: VerifyProxyApprovalReason }; + +/** + * Verify a `linkApproveByProxy` against the originating + * `approvalNeeded` and the host's freshest profile blob. Returns a + * tagged result rather than throwing — callers usually want to log + * the reason before deciding what to surface to the user. + * + * Order of checks: + * + * 1. `requestId` must match exactly. Defends against an attacker + * replaying a stale approval against a fresh request. + * 2. `domain` must match exactly. Defends against an approval signed + * under one app's separator being honored by another. + * 3. Approver must resolve to a `clients[]` entry. Identity unknown = + * reject regardless of signature validity. + * 4. Approver's `trustedApprover` flag must be set AND its + * fingerprint must be in `trustedApproverFingerprints[]` (cross- + * check via `isTrustedApprover`). + * 5. Signature must verify against the approver's `identityPublicKey`. + * 6. `expiresAt` must be in the future (gives the host a lower + * bound; the host's own pending-state is the authoritative source). + */ +export async function verifyProxyApproval( + options: VerifyProxyApprovalOptions, +): Promise { + const { request, approval, profile } = options; + + if (approval.requestId !== request.requestId) { + return { ok: false, reason: 'request-id-mismatch' }; + } + if (approval.domain !== request.domain) { + return { ok: false, reason: 'domain-mismatch' }; + } + + const approver = findClientByFingerprint(profile, approval.approverFingerprint); + if (!approver) { + return { ok: false, reason: 'unknown-approver' }; + } + if (!isTrustedApprover(profile, approval.approverFingerprint)) { + return { ok: false, reason: 'not-trusted' }; + } + + const now = (options.now ?? Date.now)(); + if (now > request.expiresAt) { + return { ok: false, reason: 'expired' }; + } + + let pubkey: Uint8Array; + let sig: Uint8Array; + try { + pubkey = hexToBytes(approver.identityPublicKey); + sig = hexToBytes(approval.signature); + } catch { + return { ok: false, reason: 'bad-signature' }; + } + if (pubkey.length !== 32 || sig.length !== 64) { + return { ok: false, reason: 'bad-signature' }; + } + + const payload = canonicalApprovalSigningBytes({ + domain: approval.domain, + requestId: approval.requestId, + hostFingerprint: request.hostFingerprint, + requestingDeviceFingerprint: request.requestingDevice.fingerprint, + decision: approval.decision, + }); + const valid = await options.crypto.verify(pubkey, payload, sig); + if (!valid) { + return { ok: false, reason: 'bad-signature' }; + } + return { ok: true, approver }; +} + +// ─── Canonical signing payload ───────────────────────────────── + +export interface ApprovalSigningInput { + domain: string; + requestId: string; + hostFingerprint: string; + requestingDeviceFingerprint: string; + decision: 'approve' | 'reject'; +} + +/** + * Build the exact bytes that get Ed25519-signed for a proxy-approval. + * + * Format (length-prefixed UTF-8, big-endian u16 lengths): + * + * u16(len(domain)) || domain + * u16(len(requestId)) || requestId + * u16(len(hostFp)) || hostFingerprint + * u16(len(requestFp)) || requestingDeviceFingerprint + * u16(len(decision)) || decision + * + * Length-prefixed rather than delimiter-joined so the encoding is + * unambiguous regardless of what bytes appear in any field. u16 + * (max 65535) is plenty: domain < 256 chars by convention, + * fingerprint ≈ 71 chars, requestId 32 hex chars, decision 6-7 chars. + * + * Exposed publicly so other Shade implementations (Android Kotlin, + * iOS Swift, etc.) can produce byte-identical signing input from + * test vectors without depending on this TypeScript code. + */ +export function canonicalApprovalSigningBytes( + input: ApprovalSigningInput, +): Uint8Array { + const fields: Uint8Array[] = [ + TEXT.encode(input.domain), + TEXT.encode(input.requestId), + TEXT.encode(input.hostFingerprint), + TEXT.encode(input.requestingDeviceFingerprint), + TEXT.encode(input.decision), + ]; + for (const f of fields) { + if (f.length > 0xffff) { + throw new ValidationError( + `signing field too long: ${f.length} bytes (max 65535)`, + ); + } + } + const total = fields.reduce((sum, f) => sum + 2 + f.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const f of fields) { + out[offset] = (f.length >> 8) & 0xff; + out[offset + 1] = f.length & 0xff; + offset += 2; + out.set(f, offset); + offset += f.length; + } + return out; +} + +// ─── Hex helpers (kept local so this module has no extra deps) ── + +function bytesToHex(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0'); + return s; +} + +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new ValidationError('hex length must be even'); + if (!/^[0-9a-f]*$/.test(hex)) { + throw new ValidationError('hex must be lowercase 0-9a-f'); + } + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} diff --git a/packages/shade-sdk/src/index.ts b/packages/shade-sdk/src/index.ts index 926a373..34bb270 100644 --- a/packages/shade-sdk/src/index.ts +++ b/packages/shade-sdk/src/index.ts @@ -115,6 +115,40 @@ export type { ProfilePutResult, } from './profile.js'; +// ─── V4.10 — cross-host approval routing ────────────────── +export { + emptyCanonicalProfile, + parseCanonicalProfile, + serializeCanonicalProfile, + upsertHost, + removeHost, + upsertClient, + removeClient, + setTrustedApprover, + isTrustedApprover, + findClientByFingerprint, + findClientByAddress, + buildApprovalRequest, + signProxyApproval, + verifyProxyApproval, + canonicalApprovalSigningBytes, + DEFAULT_APPROVAL_DOMAIN, +} from './approval.js'; +export type { + CanonicalProfileBlob, + ProfileHostEntry, + ProfileClientEntry, + ApprovalRequestingDevice, + ApprovalRequestFrame, + ProxyApprovalFrame, + BuildApprovalRequestOptions, + SignProxyApprovalOptions, + VerifyProxyApprovalOptions, + VerifyProxyApprovalReason, + VerifyProxyApprovalResult, + ApprovalSigningInput, +} from './approval.js'; + // ─── Web Workers crypto (V3.8) ───────────────────────────── export { createWorkerCryptoProvider, diff --git a/packages/shade-sdk/tests/approval.test.ts b/packages/shade-sdk/tests/approval.test.ts new file mode 100644 index 0000000..681207d --- /dev/null +++ b/packages/shade-sdk/tests/approval.test.ts @@ -0,0 +1,552 @@ +import { describe, test, expect } from 'bun:test'; +import { + emptyCanonicalProfile, + parseCanonicalProfile, + serializeCanonicalProfile, + upsertHost, + upsertClient, + removeClient, + setTrustedApprover, + isTrustedApprover, + findClientByFingerprint, + findClientByAddress, + buildApprovalRequest, + signProxyApproval, + verifyProxyApproval, + canonicalApprovalSigningBytes, + DEFAULT_APPROVAL_DOMAIN, +} from '../src/index.js'; +import type { + CanonicalProfileBlob, + ProfileClientEntry, + ProfileHostEntry, + ApprovalRequestFrame, +} from '../src/index.js'; +import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web'; +import { ValidationError } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + globalThis.crypto.getRandomValues(buf); + return buf; +} + +function bytesToHex(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0'); + return s; +} + +function makeClient(opts: { + name: string; + trusted?: boolean; +}): { entry: ProfileClientEntry; signingSeed: Uint8Array } { + const signingSeed = randBytes(32); + const pubkey = ed25519PublicKeyFromSeed(signingSeed); + const fingerprint = `fp-${opts.name}-${bytesToHex(pubkey).slice(0, 8)}`; + const entry: ProfileClientEntry = { + address: `device:${opts.name}`, + identityPublicKey: bytesToHex(pubkey), + identityFingerprint: fingerprint, + name: opts.name, + kind: 'mobile', + addedAt: 1_700_000_000_000, + }; + if (opts.trusted) entry.trustedApprover = true; + return { entry, signingSeed }; +} + +function makeHost(): ProfileHostEntry { + return { + address: 'device:host-server', + name: 'Server', + kind: 'server', + addedAt: 1_700_000_000_000, + }; +} + +describe('Canonical profile schema', () => { + test('emptyCanonicalProfile is well-formed and round-trips', () => { + const blob = emptyCanonicalProfile(123); + expect(blob.version).toBe(1); + expect(blob.hosts).toEqual([]); + expect(blob.clients).toEqual([]); + expect(blob.trustedApproverFingerprints).toEqual([]); + expect(blob.updatedAt).toBe(123); + + const bytes = serializeCanonicalProfile(blob); + const parsed = parseCanonicalProfile(bytes); + expect(parsed).toEqual(blob); + }); + + test('upsertHost replaces by address and bumps updatedAt', () => { + let blob = emptyCanonicalProfile(0); + const host = makeHost(); + blob = upsertHost(blob, host, 100); + expect(blob.hosts).toEqual([host]); + expect(blob.updatedAt).toBe(100); + + const renamed = { ...host, name: 'Server (renamed)' }; + blob = upsertHost(blob, renamed, 200); + expect(blob.hosts).toHaveLength(1); + expect(blob.hosts[0]!.name).toBe('Server (renamed)'); + expect(blob.updatedAt).toBe(200); + }); + + test('upsertClient denormalizes trustedApproverFingerprints', () => { + let blob = emptyCanonicalProfile(0); + const a = makeClient({ name: 'phone-a', trusted: true }); + const b = makeClient({ name: 'phone-b', trusted: false }); + blob = upsertClient(blob, a.entry, 100); + blob = upsertClient(blob, b.entry, 200); + + expect(blob.clients).toHaveLength(2); + expect(blob.trustedApproverFingerprints).toEqual([a.entry.identityFingerprint]); + expect(isTrustedApprover(blob, a.entry.identityFingerprint)).toBe(true); + expect(isTrustedApprover(blob, b.entry.identityFingerprint)).toBe(false); + }); + + test('setTrustedApprover toggles the flag and the denormalized list', () => { + let blob = emptyCanonicalProfile(0); + const c = makeClient({ name: 'phone', trusted: false }); + blob = upsertClient(blob, c.entry); + expect(blob.trustedApproverFingerprints).toEqual([]); + + blob = setTrustedApprover(blob, c.entry.identityFingerprint, true, 100); + expect(blob.trustedApproverFingerprints).toEqual([c.entry.identityFingerprint]); + expect(blob.clients[0]!.trustedApprover).toBe(true); + expect(blob.updatedAt).toBe(100); + + blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 200); + expect(blob.trustedApproverFingerprints).toEqual([]); + expect(blob.clients[0]!.trustedApprover).toBeUndefined(); + expect(blob.updatedAt).toBe(200); + + // No-op toggle to existing state returns the same blob. + const before = blob; + blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 999); + expect(blob).toBe(before); + }); + + test('removeClient cleans up trustedApproverFingerprints', () => { + let blob = emptyCanonicalProfile(0); + const c = makeClient({ name: 'phone', trusted: true }); + blob = upsertClient(blob, c.entry); + expect(blob.trustedApproverFingerprints).toHaveLength(1); + + blob = removeClient(blob, c.entry.identityFingerprint); + expect(blob.clients).toEqual([]); + expect(blob.trustedApproverFingerprints).toEqual([]); + }); + + test('findClientByFingerprint and findClientByAddress', () => { + let blob = emptyCanonicalProfile(0); + const c = makeClient({ name: 'phone' }); + blob = upsertClient(blob, c.entry); + + expect(findClientByFingerprint(blob, c.entry.identityFingerprint)?.address).toBe( + c.entry.address, + ); + expect(findClientByAddress(blob, c.entry.address)?.identityFingerprint).toBe( + c.entry.identityFingerprint, + ); + expect(findClientByFingerprint(blob, 'unknown')).toBeNull(); + expect(findClientByAddress(blob, 'unknown')).toBeNull(); + }); + + test('parseCanonicalProfile rejects malformed input', () => { + expect(() => parseCanonicalProfile('not json')).toThrow(ValidationError); + expect(() => parseCanonicalProfile('[]')).toThrow(ValidationError); + expect(() => parseCanonicalProfile('{"version":2}')).toThrow(ValidationError); + expect(() => + parseCanonicalProfile( + JSON.stringify({ + version: 1, + clients: [{ address: 'x', name: 'x', kind: 'm', addedAt: 0 }], + }), + ), + ).toThrow(ValidationError); // missing identityPublicKey + expect(() => + parseCanonicalProfile( + JSON.stringify({ + version: 1, + clients: [ + { + address: 'x', + name: 'x', + kind: 'm', + addedAt: 0, + identityPublicKey: 'NOTHEX', + identityFingerprint: 'x', + }, + ], + }), + ), + ).toThrow(ValidationError); // identityPublicKey not 64 hex + }); + + test('parsed blob is fully equal to the input via JSON round-trip', () => { + let blob = emptyCanonicalProfile(1); + const host = makeHost(); + const c = makeClient({ name: 'phone', trusted: true }); + blob = upsertHost(blob, host, 2); + blob = upsertClient(blob, c.entry, 3); + blob.signedBy = 'aabbccdd'; + + const bytes = serializeCanonicalProfile(blob); + const parsed = parseCanonicalProfile(bytes); + expect(parsed).toEqual(blob); + }); +}); + +describe('Approval signing payload', () => { + test('canonicalApprovalSigningBytes is deterministic', () => { + const a = canonicalApprovalSigningBytes({ + domain: 'shade-link-approve-v1', + requestId: 'aabbccddeeff00112233445566778899', + hostFingerprint: '11111 22222 33333 44444', + requestingDeviceFingerprint: '55555 66666 77777 88888', + decision: 'approve', + }); + const b = canonicalApprovalSigningBytes({ + domain: 'shade-link-approve-v1', + requestId: 'aabbccddeeff00112233445566778899', + hostFingerprint: '11111 22222 33333 44444', + requestingDeviceFingerprint: '55555 66666 77777 88888', + decision: 'approve', + }); + expect(Buffer.from(a).toString('hex')).toBe(Buffer.from(b).toString('hex')); + }); + + test('different decision produces different signing bytes', () => { + const base = { + domain: 'shade-link-approve-v1', + requestId: 'aabbccddeeff00112233445566778899', + hostFingerprint: 'h', + requestingDeviceFingerprint: 'r', + }; + const approveBytes = canonicalApprovalSigningBytes({ ...base, decision: 'approve' }); + const rejectBytes = canonicalApprovalSigningBytes({ ...base, decision: 'reject' }); + expect(Buffer.from(approveBytes).toString('hex')).not.toBe( + Buffer.from(rejectBytes).toString('hex'), + ); + }); + + test('different domain produces different signing bytes', () => { + const a = canonicalApprovalSigningBytes({ + domain: 'shade-link-approve-v1', + requestId: 'r', + hostFingerprint: 'h', + requestingDeviceFingerprint: 'd', + decision: 'approve', + }); + const b = canonicalApprovalSigningBytes({ + domain: 'prism-link-approve-v1', + requestId: 'r', + hostFingerprint: 'h', + requestingDeviceFingerprint: 'd', + decision: 'approve', + }); + expect(Buffer.from(a).toString('hex')).not.toBe(Buffer.from(b).toString('hex')); + }); +}); + +describe('Build / sign / verify proxy approval', () => { + function buildScenario() { + const phone = makeClient({ name: 'phone', trusted: true }); + let profile = emptyCanonicalProfile(0); + profile = upsertHost(profile, makeHost()); + profile = upsertClient(profile, phone.entry); + + const request = buildApprovalRequest({ + hostAddress: 'device:host-server', + hostFingerprint: 'host-fp-12345', + requestingDevice: { + fingerprint: 'cafe-laptop-fp-67890', + deviceName: 'cafe-laptop', + userAgent: 'Mozilla/5.0', + ipHint: '203.0.113.7', + }, + crypto, + }); + + return { phone, profile, request }; + } + + test('happy path: signed approve verifies', async () => { + const { phone, profile, request } = buildScenario(); + + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + + expect(approval.kind).toBe('linkApproveByProxy'); + expect(approval.requestId).toBe(request.requestId); + expect(approval.signature.length).toBe(128); // 64-byte sig as hex + + const verdict = await verifyProxyApproval({ + request, + approval, + profile, + crypto, + }); + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect(verdict.approver.address).toBe(phone.entry.address); + } + }); + + test('happy path: signed reject verifies', async () => { + const { phone, profile, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'reject', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + const verdict = await verifyProxyApproval({ request, approval, profile, crypto }); + expect(verdict.ok).toBe(true); + }); + + test('replay against a different request fails (request-id-mismatch)', async () => { + const { phone, profile, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + + const otherRequest: ApprovalRequestFrame = { + ...request, + requestId: 'ffffffffffffffffffffffffffffffff', + }; + const verdict = await verifyProxyApproval({ + request: otherRequest, + approval, + profile, + crypto, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('request-id-mismatch'); + }); + + test('decision tampered after signing fails (bad-signature)', async () => { + const { phone, profile, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + + const tampered = { ...approval, decision: 'reject' as const }; + const verdict = await verifyProxyApproval({ + request, + approval: tampered, + profile, + crypto, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('bad-signature'); + }); + + test('host fingerprint substitution fails (bad-signature)', async () => { + const { phone, profile, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + + // Verifier sees the same approval but a different host fingerprint + // (simulates an attacker forwarding an approval to a different host). + const swappedRequest: ApprovalRequestFrame = { + ...request, + hostFingerprint: 'evil-host-fp', + }; + const verdict = await verifyProxyApproval({ + request: swappedRequest, + approval, + profile, + crypto, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('bad-signature'); + }); + + test('domain mismatch is rejected before signature check', async () => { + const { phone, profile, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + const tampered = { ...approval, domain: 'prism-link-approve-v1' }; + const verdict = await verifyProxyApproval({ + request, + approval: tampered, + profile, + crypto, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('domain-mismatch'); + }); + + test('unknown approver fingerprint fails', async () => { + const { phone, profile, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + const lying = { ...approval, approverFingerprint: 'no-such-fingerprint' }; + const verdict = await verifyProxyApproval({ + request, + approval: lying, + profile, + crypto, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('unknown-approver'); + }); + + test('revoked approver (trustedApprover off) fails with not-trusted', async () => { + const { phone, profile: original, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + // Simulate a revoke: workstation toggles the trustedApprover flag off + // and PUTs the new blob; host re-fetches before verifying. + const revoked = setTrustedApprover( + original, + phone.entry.identityFingerprint, + false, + ); + const verdict = await verifyProxyApproval({ + request, + approval, + profile: revoked, + crypto, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('not-trusted'); + }); + + test('expired request is rejected', async () => { + const { phone, profile, request } = buildScenario(); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + const verdict = await verifyProxyApproval({ + request, + approval, + profile, + crypto, + now: () => request.expiresAt + 1, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('expired'); + }); + + test('signature with the wrong key fails', async () => { + const { phone, profile, request } = buildScenario(); + const wrongSeed = randBytes(32); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, // claim phone + approverSigningKey: wrongSeed, // but sign with someone else's key + crypto, + }); + const verdict = await verifyProxyApproval({ + request, + approval, + profile, + crypto, + }); + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe('bad-signature'); + }); + + test('default domain is `shade-link-approve-v1`', () => { + const r = buildApprovalRequest({ + hostAddress: 'h', + hostFingerprint: 'h', + requestingDevice: { fingerprint: 'r' }, + crypto, + }); + expect(r.domain).toBe(DEFAULT_APPROVAL_DOMAIN); + expect(DEFAULT_APPROVAL_DOMAIN).toBe('shade-link-approve-v1'); + }); + + test('custom domain (e.g. `prism-link-approve-v1`) survives round-trip', async () => { + const { phone, profile } = buildScenario(); + const request = buildApprovalRequest({ + hostAddress: 'device:host-server', + hostFingerprint: 'h', + requestingDevice: { fingerprint: 'r' }, + crypto, + domain: 'prism-link-approve-v1', + }); + const approval = await signProxyApproval({ + request, + decision: 'approve', + approverFingerprint: phone.entry.identityFingerprint, + approverSigningKey: phone.signingSeed, + crypto, + }); + expect(approval.domain).toBe('prism-link-approve-v1'); + const verdict = await verifyProxyApproval({ request, approval, profile, crypto }); + expect(verdict.ok).toBe(true); + }); + + test('requestId is 32 lowercase hex chars (128 bits)', () => { + const r = buildApprovalRequest({ + hostAddress: 'h', + hostFingerprint: 'h', + requestingDevice: { fingerprint: 'r' }, + crypto, + }); + expect(/^[0-9a-f]{32}$/.test(r.requestId)).toBe(true); + }); + + test('two consecutive builds produce distinct requestIds', () => { + const a = buildApprovalRequest({ + hostAddress: 'h', + hostFingerprint: 'h', + requestingDevice: { fingerprint: 'r' }, + crypto, + }); + const b = buildApprovalRequest({ + hostAddress: 'h', + hostFingerprint: 'h', + requestingDevice: { fingerprint: 'r' }, + crypto, + }); + expect(a.requestId).not.toBe(b.requestId); + }); +}); diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index 767ad33..f0495d0 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index 3733065..3559e12 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-encrypted", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json index 85a094d..7221817 100644 --- a/packages/shade-storage-indexeddb/package.json +++ b/packages/shade-storage-indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-indexeddb", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 039a2f7..ade9c99 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-postgres", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index ddf4228..7f89762 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index 508ded8..e588766 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index 29bfbd2..2cb5fb5 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index e509668..8b79c1b 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index bacae86..09dc680 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index c97fa18..f1b0b36 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index ad47154..526cf64 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.9.0", + "version": "4.10.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts",