`Opake<T, R, S>` previously took `Option<Identity>`, but 16 methods
hard-required it via `require_identity()?` and three more silently
returned empty lists when identity was absent. The optionality was a
lie — callers couldn't use an `Opake` without identity anyway, and the
silent-empty-list branches hid configuration bugs (a missing signing
key looked indistinguishable from "no data").
Meanwhile, the pair flow on the new device leaked the ephemeral X25519
private key across the WASM/JS boundary: `createPairRequest` returned
it, JS stashed it in sessionStorage, `receivePairResponse` took it
back. The resulting Identity DTO also crossed in full. Both violated
the WASM-owns-crypto-material policy in CLAUDE.md.
Those two problems were entangled because the pair methods lived on
`Opake` but needed to work pre-identity. Fixing #9 alone would have
left pair as an awkward "identity-less method on the identity-requiring
type"; fixing the key leak alone would still have needed the optional
plumbing. Combined, they produce a clean two-phase model:
1. Bootstrap: static methods that populate Identity in Storage —
`startLogin` / `completeLogin` / `createPairRequest` +
`awaitPairCompletion`.
2. Use: `Opake.init({storage, did})` returns a handle that *always*
has Identity. Returns `Error::IdentityMissing` when bootstrap
hasn't run yet, signalling the UI to route to recovery or pair.
Core (opake-core):
- Drop `Option<Identity>` from `Opake`; `identity()` returns `&Identity`.
- `for_account` returns `Error::IdentityMissing` on missing identity.
- Delete `require_identity()`; replace 16 call sites with `&self.identity`.
- Replace three silent-tolerate branches with a `require_signing_key()`
helper that actually errors. Identity migration runs in `for_account`,
so the error only fires on a custom `Opake::new` with a legacy identity.
- New `Storage` trait methods: `save_pair_state` / `load_pair_state` /
`delete_pair_state`. Implemented by FileStorage (0600 file per rkey),
IndexedDbStorage (new `pairStates` table, Dexie v2 in-place migration),
MemoryStorage (Map with defensive copy on read/write).
- Rewrite `pairing::create_pair_request` to take `&S: Storage` + `did`
and persist the ephemeral privkey internally. Returns
`PairRequestInfo { uri, rkey, ephemeral_public_key }` — no private
material leaves the crate.
- New `pairing::try_complete_pair` polls once, and on match decrypts
the Identity, saves it via Storage, wipes pair_state, and tears down
both on-PDS records. `complete_pair_response` exposed for tests.
- New `pairing::cancel_pair_request` for UI back-out; tolerates
NotFound on either the storage side or the PDS side.
- New `opake::authenticated_client` helper — builds an XrpcClient from
a session in Storage without requiring identity. Shared by native and
WASM bootstrap paths.
WASM (opake-wasm):
- Drop `createPairRequest` / `receivePairResponse` / `listPairResponses`
/ `cleanupPairRecords` from `OpakeContext`. None should ever have
been on the handle.
- New `pair_wasm` module with three top-level exports that take a DID +
JsStorageAdapter: `createPairRequest`, `tryCompletePair`,
`cancelPairRequest`. Ephemeral private key and Identity DTO never
cross the boundary; only the ephemeral public key (for fingerprint
display) and `{uri, rkey}` go out.
- `approvePairRequest` stays on the handle — it has Identity and uses
the full Opake context.
- Extend `JsStorageAdapter` extern with the three pair_state methods.
SDK (@opake/sdk):
- `Opake.createPairRequest(storage, did)` / `Opake.awaitPairCompletion(
storage, did, rkey, options?)` / `Opake.cancelPairRequest(...)` as
static methods. `awaitPairCompletion` owns the polling loop with
configurable interval, optional timeout, and AbortSignal support.
- Existing-device instance methods unchanged except the four dropped ones.
- Remove `PairResponseRecord` and `ephemeralPrivateKey` from the public
surface. Remove the matching schema fields.
- Extend `Storage` interface + `createStorageAdapter` + `MemoryStorage`
+ `IndexedDbStorage` to mirror the Rust trait.
CLI:
- `pair request` shrinks from ~90 to ~40 lines. `create_pair_request` +
a tokio sleep loop calling `try_complete_pair`. No manual keypair
juggling; cleanup is the free fn's responsibility.
- `recover` uses the raw client + raw storage pattern: derive identity,
save via Storage, *then* construct Opake. Confirms the post-refactor
bootstrap shape.
Web:
- `apps/web/src/lib/pairing.ts` split cleanly: new-device helpers take
Storage + DID, existing-device helpers go through `getOpake()`.
- `pair.request.lazy.tsx` is now a single useEffect: call
`createPairRequest`, render fingerprint, `await awaitPairCompletion`,
call `finalizePairing`. AbortController handles unmount cleanup.
No more setInterval dance or ref-based private key stashing.
- `saveReceivedIdentity(identity)` renamed → `finalizePairing()`: the
Identity DTO no longer crosses the boundary, so the store just needs
to rebuild Opake + refresh derived state.
- Rename the TopBar menu item "Encryption Keys" → "Devices" to match
the route and describe what the page actually does.
Tests:
- New `pairing::request::tests` verifies `create_pair_request`
persists the ephemeral privkey and that the returned public key is
the DH partner of the persisted private key.
- New `config::tests` cover the three FileStorage pair_state
operations plus the 0600 file permission invariant and the
idempotent-delete-on-missing contract.