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>
793 lines
27 KiB
TypeScript
793 lines
27 KiB
TypeScript
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;
|
|
}
|