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:
76
CHANGELOG.md
76
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/),
|
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).
|
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
|
## [4.9.0] — 2026-05-09 — relay-side encrypted blob primitive + SDK `Profile` namespace
|
||||||
|
|
||||||
Prism filed a feature request
|
Prism filed a feature request
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -115,6 +115,40 @@ export type {
|
|||||||
ProfilePutResult,
|
ProfilePutResult,
|
||||||
} from './profile.js';
|
} 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) ─────────────────────────────
|
// ─── Web Workers crypto (V3.8) ─────────────────────────────
|
||||||
export {
|
export {
|
||||||
createWorkerCryptoProvider,
|
createWorkerCryptoProvider,
|
||||||
|
|||||||
552
packages/shade-sdk/tests/approval.test.ts
Normal file
552
packages/shade-sdk/tests/approval.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.9.0",
|
"version": "4.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user