release(v4.10.0): cross-host approval routing primitives in @shade/sdk
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:
2026-05-09 17:09:59 +02:00
parent 80c410f518
commit 1bd7037a6d
29 changed files with 1479 additions and 25 deletions

View File

@@ -5,6 +5,82 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.10.0] — 2026-05-09 — cross-host approval routing primitives
Prism filed a follow-up feature request
(`cross-host-approval-routing.md`) building on V4.9: now that the
encrypted profile blob is shipped, headless servers and away-from-PC
scenarios still can't approve a `linkRequest` from a new device
because there's no GUI to pop a dialog. Solution: a *trusted-approver*
phone signs an Ed25519 approval that any host can verify against the
freshest profile blob, even if the host has never spoken to the phone
before (X3DH-on-first-send via the existing `Shade.send` handles
session bootstrap).
The FR's questions (1) "is X3DH-on-first-send supported?" and
(5) "is there a broadcast helper to N addresses with X3DH-on-first?"
are both answered by *yes, `Shade.send` already does this per call*
— no new relay primitives needed. What this release ships is the
*shape* every Shade app would otherwise reinvent: a canonical
profile-blob schema and the build/sign/verify trio for proxy approvals.
**SDK (`@shade/sdk`)**
- Canonical profile-blob schema: `CanonicalProfileBlob` with
`hosts[]`, `clients[]`, and a denormalized
`trustedApproverFingerprints[]`. `parseCanonicalProfile` /
`serializeCanonicalProfile` round-trip JSON; mutators
(`upsertHost`, `upsertClient`, `setTrustedApprover`,
`removeClient`, ...) are immutable and re-derive the
denormalized list on every change so it can't drift.
- `ProfileClientEntry` stores both `identityPublicKey` (32-byte hex,
used by `verifyProxyApproval`) and `identityFingerprint` (safety-
number for display). The FR sketched only the fingerprint; storing
the public key in-band drops the prekey-server dependency at
verify-time and lets any host check signatures from a fresh
profile read alone.
- Approval frames: `ApprovalRequestFrame` (`kind: 'approvalNeeded'`)
and `ProxyApprovalFrame` (`kind: 'linkApproveByProxy'`).
`buildApprovalRequest` mints a 128-bit hex `requestId` and a
configurable expiry (default 5 min).
- `signProxyApproval` / `verifyProxyApproval` use a length-prefixed
binary signing payload (`canonicalApprovalSigningBytes`) that binds
domain, requestId, host fingerprint, requesting-device fingerprint,
and decision. Length-prefixed (u16 BE) so any platform — Kotlin,
Swift, Go — can produce byte-identical bytes from test vectors
without a JSON canonicalizer.
- Domain separator: `DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1'`.
Apps with their own canonical name (Prism uses
`prism-link-approve-v1`) override via `domain`. The frame carries
the domain so a verifier rejects mismatch before signature check.
- `verifyProxyApproval` returns a tagged result instead of throwing.
Reasons: `request-id-mismatch`, `domain-mismatch`,
`unknown-approver`, `not-trusted`, `bad-signature`, `expired`.
Hosts log the reason and decide what to surface to the user.
- `isTrustedApprover` cross-checks the per-client `trustedApprover`
flag AND the denormalized `trustedApproverFingerprints[]`. Both
must agree — defends against a partially-written blob.
**Threat model — what's new**
- Compromised relay still can't read or forge approvals. The
approval signature is verified against the approver's long-term
Ed25519 identity key stored in the profile blob, which the relay
can't decrypt or rewrite (profile-blob TOFU on owner-pubkey from
V4.9 still applies).
- Revocation TOCTOU: hosts MUST refetch the profile blob fresh
before honoring `linkApproveByProxy`. The `verifyProxyApproval`
signature accepts the blob as a parameter — caller controls
freshness. One extra `Profile.get` RTT per approval.
- The signature is belt-and-suspenders on top of the bilateral E2EE
that delivers the frame. The E2EE channel already authenticates
the sender's session, but the long-term identity binding means an
approval is verifiable independently of session state.
**No relay or transport changes**. All app-level. Hosts persist their
own pending-`requestId` set for replay protection; that's app state
the SDK doesn't need to track.
## [4.9.0] — 2026-05-09 — relay-side encrypted blob primitive + SDK `Profile` namespace
Prism filed a feature request