release(v4.10.0): cross-host approval routing primitives in @shade/sdk
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
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) <noreply@anthropic.com>
This commit is contained in:
792
packages/shade-sdk/src/approval.ts
Normal file
792
packages/shade-sdk/src/approval.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<T>(
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<ApprovalRequestingDevice, 'receivedAt'> & {
|
||||
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<CryptoProvider, 'sign'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ProxyApprovalFrame> {
|
||||
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<CryptoProvider, 'verify'>;
|
||||
/** 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<VerifyProxyApprovalResult> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user