From dbb3a090d89dd9e7fd0f90535ad5333aefa34926 Mon Sep 17 00:00:00 2001 From: Sterister Date: Tue, 5 May 2026 17:58:45 +0200 Subject: [PATCH] release(v4.4.0): public accessor for device identity public key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the local device's 32-byte Ed25519 identity public key on Shade so apps can hand it to their own backend at enrollment time for signature verification, key pinning or per-device safety-number computation. Closes the gap that forced consumers to ship placeholder random bytes their backend could store but never verify against. - @shade/sdk Shade.identityPublicKey: Promise — getter mirrors the existing fingerprint accessor. Throws pre-init, reflects the current key after rotate(), retired key preserved in retired-identities storage per existing grace-period contract. Private key remains unreachable. - Test in shade-sdk/tests/sdk.test.ts: round-trip match against the underlying storage's signingPublicKey, plus value updates after rotate(). - Lockstep version bump 4.3.0 → 4.4.0 across all 25 packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 29 +++ docs/shade-identity-public-key-accessor.md | 173 ++++++++++++++++++ packages/shade-cli/package.json | 2 +- packages/shade-core/package.json | 2 +- packages/shade-crypto-web/package.json | 2 +- packages/shade-dashboard/package.json | 2 +- packages/shade-files/package.json | 2 +- packages/shade-inbox-server/package.json | 2 +- packages/shade-inbox/package.json | 2 +- packages/shade-key-transparency/package.json | 2 +- packages/shade-keychain/package.json | 2 +- packages/shade-observability/package.json | 2 +- packages/shade-observer/package.json | 2 +- packages/shade-proto/package.json | 2 +- packages/shade-recovery/package.json | 2 +- packages/shade-sdk/package.json | 2 +- packages/shade-sdk/src/shade.ts | 20 ++ packages/shade-sdk/tests/sdk.test.ts | 22 ++- packages/shade-server/package.json | 2 +- packages/shade-storage-encrypted/package.json | 2 +- packages/shade-storage-indexeddb/package.json | 2 +- packages/shade-storage-postgres/package.json | 2 +- packages/shade-storage-sqlite/package.json | 2 +- packages/shade-streams/package.json | 2 +- packages/shade-transfer/package.json | 2 +- packages/shade-transport-bridge/package.json | 2 +- packages/shade-transport-webrtc/package.json | 2 +- packages/shade-transport/package.json | 2 +- packages/shade-widgets/package.json | 2 +- 29 files changed, 268 insertions(+), 26 deletions(-) create mode 100644 docs/shade-identity-public-key-accessor.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fb1b4..8d6bb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ 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.4.0] — 2026-05-05 — Public accessor for the device's identity public key + +Browser-based Shade consumers building enrollment flows had no way to +hand the device's actual Ed25519 identity public key to their own +backend — the key was reachable only via the private +`storage.getIdentityKeyPair()` call inside `Shade`. Apps shipped with +placeholder bytes (`crypto.getRandomValues(new Uint8Array(32))`) that +the backend stored but couldn't verify against, deferring real +cryptographic device binding until the SDK exposed the key. + +### Added + +#### `@shade/sdk` +- `Shade.identityPublicKey: Promise` — getter returning the + local device's 32-byte Ed25519 identity public key. Mirrors the + `fingerprint` accessor shape. Throws if accessed before + `initialize()`. Reflects the current key after `rotate()`; the + previous key remains in retired-identities storage for the + configured grace period. Use `fingerprint` (12-group safety number) + for human side-channel comparison; use `identityPublicKey` when + handing the raw key to a backend for signature verification or + pinning. + +### Tests +- `packages/shade-sdk/tests/sdk.test.ts` — `identityPublicKey exposes + the device Ed25519 key and tracks rotation` covers the round-trip + match against the underlying storage and that the value updates + after `rotate()`. + ## [4.3.0] — 2026-05-05 — Browser persistence via `@shade/storage-indexeddb` Browser-based Shade consumers had no path to session persistence: the only diff --git a/docs/shade-identity-public-key-accessor.md b/docs/shade-identity-public-key-accessor.md new file mode 100644 index 0000000..966ebef --- /dev/null +++ b/docs/shade-identity-public-key-accessor.md @@ -0,0 +1,173 @@ +# Feature Request — Public accessor for the device's identity public key + +**To**: Shade SDK team +**From**: Dispatch (browser-based Shade consumer) +**Target**: Shade SDK 4.4.x (or whichever release vehicle fits) +**Priority**: medium — unblocks real per-device fingerprint binding at +enrollment time; consumers ship with placeholder keys until then + +Thanks for shipping `@shade/storage-indexeddb` so quickly — that unblocked +Dispatch's Slice 2.5 (persistent browser identity). During integration we +hit one more gap that's worth raising as a separate FR. + +--- + +## Summary + +Expose the local device's Ed25519 identity public key as a public accessor +on `Shade`, so applications can hand it to their own backend at enrollment +time for per-device verification, audit, or peer-fingerprint computation. + +Today the SDK exposes `myAddress` and `fingerprint`, but the underlying +identity public key — the cryptographic root that everything else binds +to — is reachable only via the private `this.storage.getIdentityKeyPair()` +call inside `Shade`. Consumers building enrollment flows have no way to +hand the real key over. + +## Problem + +A common pattern in Shade-using apps is: + +1. Browser device generates Shade identity (via `createShade`) +2. User enters an enrollment token +3. Browser POSTs to its backend: `{ token, address, identityPublicKey, ... }` +4. Backend records the device, computes/stores a safety number from the + identity key, opens a Shade session + +Step 3 today has nowhere to get a real `identityPublicKey` from. Dispatch +currently sends `crypto.getRandomValues(new Uint8Array(32))` formatted as +hex, with this comment in the source: + +```ts +/** + * The browser submits a hex public-key field at enroll time so the schema + * stays stable. Wiring this to the SDK-generated identity key requires a + * Shade SDK addition (no public accessor for the raw identity key today). + */ +export function generatePlaceholderIdentityPublicKey(): string { ... } +``` + +The backend stores this placeholder but cannot verify anything against it, +because it's not actually the device's key. Real cryptographic binding is +deferred until the SDK exposes the underlying key. + +## Why `fingerprint` isn't sufficient + +`Shade.fingerprint` returns a 12-groups-of-5-digits safety-number string +designed for human side-channel comparison. That's the right output for a +"compare these digits with your friend" UX, but it's a derived format, not +the raw key. Backends that want to: + +- Store the key for later signature verification +- Compute their own safety-number representation (Dispatch uses + `deterministicSafetyNumber(localAddr, peerAddr)` based on the raw bytes) +- Re-derive the fingerprint after an identity rotation + +…all need access to the raw 32-byte Ed25519 public key. + +## Proposed API + +Add a single async accessor on `Shade`: + +```ts +class Shade { + /** + * The local device's Ed25519 identity public key (32 bytes). + * + * Stable for the lifetime of the identity. After `rotateIdentity()` + * this returns the new key; the old key is preserved in retired- + * identities storage for the configured grace period. + */ + get identityPublicKey(): Promise; +} +``` + +Internally: + +```ts +get identityPublicKey(): Promise { + if (!this.initialized) throw new Error('Not initialized'); + return this.storage.getIdentityKeyPair().then((kp) => { + if (!kp) throw new Error('Identity not yet generated'); + return kp.publicKey; + }); +} +``` + +If returning a getter that produces a Promise feels off, the equivalent +method form is fine: + +```ts +async getIdentityPublicKey(): Promise { ... } +``` + +Either shape works for consumers — pick whichever matches existing SDK +conventions. + +## Alternative considered: object accessor + +Returning an object would leave room to expose other identity-related +fields later without a breaking change: + +```ts +get identity(): Promise<{ + address: string; + publicKey: Uint8Array; + fingerprint: string; + // future: registrationId, createdAt, etc. +}>; +``` + +This would mildly duplicate `myAddress` + `fingerprint`, but consolidates +identity-related accessors. Not a blocker for shipping the simpler +single-purpose accessor — just flagging the option in case the SDK is +considering broader API ergonomics. + +## What this unblocks + +For Dispatch specifically: + +- Real device identity binding at enrollment (replaces the placeholder + `crypto.randomUUID()`-derived hex bytes the backend currently stores) +- Server-side `computePeerSafetyNumber()` can use the real key instead of + the deterministic-from-address stand-in (`shade-identity-provider.ts:151`) +- Future signature-based device authentication (sign a challenge with the + device's identity key during enrollment) becomes possible without + another SDK round + +For other Shade consumers: + +- Any app that hands a device key to its own backend for enrollment — + multi-device pairing flows, contact verification UIs, push-notification + targeting — gets an actual key to work with + +## Acceptance criteria + +1. New accessor exposed on `Shade` (getter or async method, SDK's + preference) +2. Returns the 32-byte Ed25519 identity public key +3. Returns the **current** key after `rotateIdentity()` +4. Throws (or resolves to a clear error) when called before + `initialize()` completes +5. Documented alongside `myAddress` and `fingerprint` in the SDK reference +6. One unit test in `shade-sdk` confirming the returned bytes match what + `storage.getIdentityKeyPair()` holds +7. Mentioned in the changelog under the next release + +## Out of scope + +- **Private key access** — consumers should never need it; signature + operations go through `shade.send()` etc. Don't expose the secret half. +- **Cross-peer key lookup** — getting *another* peer's identity key is a + separate concern (related to peer-verification storage), not what this + FR is about. This is strictly the local device's own key. +- **Format conversions** — base64/hex/PEM helpers don't belong in the SDK. + Consumers can encode the raw bytes however their wire format requires. + +## Why this can't be done in consumer-land + +The identity keypair is generated by `MemoryStorage` / `SQLiteStorage` / +`IndexedDBStorage` and consumed by `ShadeSessionManager`. Consumers can't +reach into either layer without breaching the SDK's encapsulation. A +public accessor is the only path that doesn't require monkey-patching +private fields. diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 93efb62..11e9a41 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index e7d3d26..53e787c 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index 9d89320..137d262 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index f7875bf..b498c22 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 0eb6fcf..0ecfc2e 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index 735dae4..38fa885 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index 3fa77f5..ada0c52 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index c7d8357..ad365b4 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index 4b2d842..ff339e5 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index 93432f0..283d732 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index 93e94c0..5bd5a57 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 50a404f..25810db 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index cbd55d2..ff69381 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index 08d11dc..17279ab 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/src/shade.ts b/packages/shade-sdk/src/shade.ts index 0f5bb6f..4633da6 100644 --- a/packages/shade-sdk/src/shade.ts +++ b/packages/shade-sdk/src/shade.ts @@ -299,6 +299,26 @@ export class Shade { return this.address; } + /** + * The local device's Ed25519 identity public key (32 bytes). + * + * Stable for the lifetime of the identity. After {@link rotate} this + * reflects the new key; the previous key is preserved in retired- + * identities storage for the configured grace period. + * + * Hand this to your application's backend at enrollment time so it + * can verify signatures from this device, compute its own safety- + * number representation, or pin the key for later attestation. Use + * {@link fingerprint} instead for human side-channel comparison. + */ + get identityPublicKey(): Promise { + if (!this.initialized) throw new Error('Not initialized'); + return this.storage.getIdentityKeyPair().then((kp) => { + if (!kp) throw new Error('Identity not yet generated'); + return kp.signingPublicKey; + }); + } + /** * `@shade/files` namespace — high-level entry point for E2EE filesystem * RPC. Lazily creates the underlying channel + streams bridges on first diff --git a/packages/shade-sdk/tests/sdk.test.ts b/packages/shade-sdk/tests/sdk.test.ts index 507dca1..5af99e4 100644 --- a/packages/shade-sdk/tests/sdk.test.ts +++ b/packages/shade-sdk/tests/sdk.test.ts @@ -5,7 +5,7 @@ import { MemoryPrekeyStore, PrekeyServerEvents, } from '@shade/server'; -import { SubtleCryptoProvider } from '@shade/crypto-web'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; const crypto = new SubtleCryptoProvider(); @@ -182,6 +182,26 @@ describe('createShade — happy path', () => { const newFp = await alice.fingerprint; expect(newFp).not.toBe(oldFp); }); + + test('identityPublicKey exposes the device Ed25519 key and tracks rotation', async () => { + const storage = new MemoryStorage(); + alice = await createShade({ prekeyServer: server.url, address: 'alice', storage }); + + const pk = await alice.identityPublicKey; + expect(pk).toBeInstanceOf(Uint8Array); + expect(pk.length).toBe(32); + + // Matches what the underlying storage holds + const stored = await storage.getIdentityKeyPair(); + expect(stored).not.toBeNull(); + expect(pk).toEqual(stored!.signingPublicKey); + + // Reflects the new key after rotate (acceptance criteria #3) + await alice.rotate(); + const pkAfter = await alice.identityPublicKey; + expect(pkAfter.length).toBe(32); + expect(pkAfter).not.toEqual(pk); + }); }); describe('createShade — validation', () => { diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index ea2dbe1..3ba3dc5 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index 1b01418..b0490ff 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-encrypted", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json index 24ab832..5266dbc 100644 --- a/packages/shade-storage-indexeddb/package.json +++ b/packages/shade-storage-indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-indexeddb", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 24ce21f..09cab38 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-postgres", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index cb7a1a9..4d34bc8 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index e43b8c7..5226ee3 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index d5d9048..bdfaaf5 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index 3bbeb44..ec86bc6 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index 03a821a..ad1ad49 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index 626d70d..85571fc 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index 2f6bf64..f50a3e6 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.3.0", + "version": "4.4.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts",