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; }