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<Uint8Array> — 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) <noreply@anthropic.com>
6.3 KiB
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:
- Browser device generates Shade identity (via
createShade) - User enters an enrollment token
- Browser POSTs to its backend:
{ token, address, identityPublicKey, ... } - 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:
/**
* 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:
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<Uint8Array>;
}
Internally:
get identityPublicKey(): Promise<Uint8Array> {
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:
async getIdentityPublicKey(): Promise<Uint8Array> { ... }
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:
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
- New accessor exposed on
Shade(getter or async method, SDK's preference) - Returns the 32-byte Ed25519 identity public key
- Returns the current key after
rotateIdentity() - Throws (or resolves to a clear error) when called before
initialize()completes - Documented alongside
myAddressandfingerprintin the SDK reference - One unit test in
shade-sdkconfirming the returned bytes match whatstorage.getIdentityKeyPair()holds - 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.