Files
Shade/docs/shade-identity-public-key-accessor.md
Sterister dbb3a090d8 release(v4.4.0): public accessor for device identity public key
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>
2026-05-05 17:58:45 +02:00

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:

  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:

/**
 * 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

  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.