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/),
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user