this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Require Identity on Opake; contain pair ephemeral keys in Storage

`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.

+1139 -560
+18 -64
apps/cli/src/commands/pair.rs
··· 2 2 use chrono::Utc; 3 3 use clap::{Args, Subcommand}; 4 4 use log::debug; 5 - use opake_core::atproto; 6 5 use opake_core::client::Session; 7 6 use opake_core::crypto::OsRng; 8 7 use opake_core::pairing; 9 - use opake_core::records::{PairRequest, PairResponse, PAIR_RESPONSE_COLLECTION}; 8 + use opake_core::records::PairRequest; 10 9 11 10 use crate::commands::Execute; 12 11 use crate::identity; ··· 62 61 .join(":") 63 62 } 64 63 65 - // request runs on the NEW device — no identity exists yet, so ctx.opake() 66 - // can't be used (it requires identity). Keep session::load_client for the 67 - // authenticated PDS client and use raw pairing functions. 64 + // The new-device flow runs without an identity, so it goes through the 65 + // storage-backed pairing free functions rather than `Opake::for_account`. 66 + // The ephemeral private key stays in `ctx.storage` for the duration. 68 67 async fn request(ctx: &CommandContext, args: RequestArgs) -> Result<Option<Session>> { 69 68 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 70 69 71 - // Bail if this device already has an identity — use `opake account login` instead. 72 70 if identity::load_identity(&ctx.storage, &ctx.did).is_ok() { 73 71 anyhow::bail!( 74 72 "this device already has an encryption identity for {}. \ ··· 77 75 ); 78 76 } 79 77 80 - let (record_ref, ephemeral_keypair) = 81 - pairing::create_pair_request(&mut client, &Utc::now().to_rfc3339(), &mut OsRng).await?; 82 - 83 - let request_uri = &record_ref.uri; 84 - let request_at_uri = atproto::parse_at_uri(request_uri)?; 78 + let info = pairing::create_pair_request( 79 + &mut client, 80 + &ctx.storage, 81 + &ctx.did, 82 + &Utc::now().to_rfc3339(), 83 + &mut OsRng, 84 + ) 85 + .await?; 85 86 86 87 println!("Pairing request created."); 87 - println!( 88 - "Fingerprint: {}", 89 - fingerprint(&ephemeral_keypair.public_key) 90 - ); 88 + println!("Fingerprint: {}", fingerprint(&info.ephemeral_public_key)); 91 89 println!(); 92 90 println!("Run `opake pair approve` on your existing device."); 93 91 println!("Waiting for response..."); 94 92 95 - // Poll for a matching pairResponse record. 96 93 let interval = std::time::Duration::from_secs(args.interval); 97 - let response: PairResponse = loop { 98 - tokio::time::sleep(interval).await; 99 - debug!("polling for pair response..."); 100 - 101 - let page = client 102 - .list_records(PAIR_RESPONSE_COLLECTION, Some(100), None) 103 - .await?; 104 - 105 - let found = page.records.into_iter().find(|entry| { 106 - serde_json::from_value::<PairResponse>(entry.value.clone()) 107 - .map(|r| r.request == *request_uri) 108 - .unwrap_or(false) 109 - }); 110 - 111 - if let Some(entry) = found { 112 - break serde_json::from_value(entry.value)?; 94 + loop { 95 + if pairing::try_complete_pair(&mut client, &ctx.storage, &ctx.did, &info.rkey).await? { 96 + break; 113 97 } 114 - }; 115 - 116 - let received_identity = pairing::receive_pair_response( 117 - &mut client, 118 - &ctx.did, 119 - &response, 120 - &ephemeral_keypair.private_key, 121 - ) 122 - .await?; 123 - 124 - identity::save_identity(&ctx.storage, &ctx.did, &received_identity)?; 125 - println!("Identity received and saved."); 126 - 127 - // Clean up both records. 128 - let response_page = client 129 - .list_records(PAIR_RESPONSE_COLLECTION, Some(100), None) 130 - .await?; 131 - let response_rkey = response_page 132 - .records 133 - .iter() 134 - .find(|entry| { 135 - serde_json::from_value::<PairResponse>(entry.value.clone()) 136 - .map(|r| r.request == *request_uri) 137 - .unwrap_or(false) 138 - }) 139 - .map(|entry| atproto::parse_at_uri(&entry.uri)) 140 - .transpose()? 141 - .map(|uri| uri.rkey); 142 - 143 - if let Some(ref rkey) = response_rkey { 144 - pairing::cleanup_pair_records(&mut client, &request_at_uri.rkey, rkey).await?; 145 - debug!("cleaned up pairing records"); 98 + debug!("no matching response yet, sleeping {}s", args.interval); 99 + tokio::time::sleep(interval).await; 146 100 } 147 101 148 102 println!("Pairing complete.");
+12 -14
apps/cli/src/commands/recover.rs
··· 5 5 use opake_core::client::Session; 6 6 use opake_core::crypto::{derive_identity_from_mnemonic, parse_mnemonic, parse_mnemonic_grid}; 7 7 use opake_core::records::{PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; 8 + use opake_core::storage::Storage; 8 9 9 10 use crate::commands::Execute; 10 11 use crate::identity; 11 - use crate::session::CommandContext; 12 + use crate::session::{self, CommandContext}; 12 13 13 14 /// Recover encryption identity from a 24-word seed phrase 14 15 /// ··· 29 30 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 30 31 let did = &ctx.did; 31 32 32 - // Check for existing local identity. 33 33 if identity::load_identity(&ctx.storage, did).is_ok() { 34 34 anyhow::bail!( 35 35 "local identity already exists for {did}. \ ··· 54 54 55 55 let derived = derive_identity_from_mnemonic(&mnemonic, did); 56 56 57 - // Compare against published key on PDS. 58 - let mut opake = ctx.opake().await?; 59 - let mismatch = check_published_key_mismatch(&mut opake, did, &derived).await?; 57 + // Use the raw authenticated client for the published-key check: we 58 + // can't build an Opake yet (no identity in storage), and building one 59 + // after saving the identity would require a redundant round-trip. 60 + let mut client = session::load_client(&ctx.storage, did)?; 61 + let mismatch = check_published_key_mismatch(&mut client, did, &derived).await?; 60 62 61 63 if mismatch { 62 64 println!(); ··· 74 76 } 75 77 } 76 78 77 - opake.save_identity(&derived).await?; 79 + ctx.storage.save_identity(did, &derived).await?; 78 80 println!("Identity recovered and saved."); 79 81 80 - // Rebuild Opake so it loads the newly saved identity from disk. 81 - drop(opake); 82 + // Now that identity is on disk, the standard Opake path succeeds. 82 83 let mut opake = ctx.opake().await?; 83 84 opake 84 85 .publish_public_key() ··· 93 94 /// Check if the derived identity's public key matches the one published on PDS. 94 95 /// Returns `true` if there's a mismatch, `false` if keys match or no key is published. 95 96 async fn check_published_key_mismatch( 96 - opake: &mut crate::session::CliOpake, 97 + client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 97 98 did: &str, 98 99 derived: &opake_core::storage::Identity, 99 100 ) -> Result<bool> { 100 - let published = opake 101 + let published = client 101 102 .get_record(did, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY) 102 103 .await; 103 104 ··· 112 113 let derived_bytes = derived.public_key_bytes()?; 113 114 Ok(published_bytes != derived_bytes) 114 115 } 115 - Err(opake_core::error::Error::NotFound(_)) => { 116 - // No published key — no mismatch possible. First-time setup. 117 - Ok(false) 118 - } 116 + Err(opake_core::error::Error::NotFound(_)) => Ok(false), 119 117 Err(e) => Err(anyhow::anyhow!("failed to check published key: {e}")), 120 118 } 121 119 }
+1 -1
apps/cli/src/commands/workspace.rs
··· 124 124 return Ok(None); 125 125 } 126 126 127 - let private_key = opake.require_identity()?.private_key_bytes()?; 127 + let private_key = opake.identity().private_key_bytes()?; 128 128 let did = opake.did(); 129 129 130 130 for kr in &keyrings {
+49
apps/cli/src/config.rs
··· 275 275 .map_err(|e| Error::Storage(e.to_string())) 276 276 } 277 277 278 + // -- Pair state (raw bytes, 0600) ------------------------------------------ 279 + // 280 + // Stored as the raw 32-byte key, not base64. The directory + file both 281 + // inherit the 0700/0600 mode the rest of the account data uses. 282 + 283 + async fn save_pair_state( 284 + &self, 285 + did: &str, 286 + rkey: &str, 287 + private_key: &[u8], 288 + ) -> Result<(), Error> { 289 + let dir = pair_state_dir(&self.account_dir(did)); 290 + Self::ensure_sensitive_dir(&dir).map_err(|e| Error::Storage(e.to_string()))?; 291 + let path = dir.join(pair_state_filename(rkey)); 292 + Self::write_sensitive_file(&path, private_key) 293 + .map_err(|e| Error::Storage(format!("failed to write pair state: {e}"))) 294 + } 295 + 296 + async fn load_pair_state(&self, did: &str, rkey: &str) -> Result<Vec<u8>, Error> { 297 + let path = pair_state_dir(&self.account_dir(did)).join(pair_state_filename(rkey)); 298 + fs::read(&path).map_err(|e| { 299 + if e.kind() == std::io::ErrorKind::NotFound { 300 + Error::NotFound(format!("pair state {rkey}")) 301 + } else { 302 + Error::Storage(format!("failed to read pair state: {e}")) 303 + } 304 + }) 305 + } 306 + 307 + async fn delete_pair_state(&self, did: &str, rkey: &str) -> Result<(), Error> { 308 + let path = pair_state_dir(&self.account_dir(did)).join(pair_state_filename(rkey)); 309 + match fs::remove_file(&path) { 310 + Ok(()) => Ok(()), 311 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 312 + Err(e) => Err(Error::Storage(format!("failed to delete pair state: {e}"))), 313 + } 314 + } 315 + 278 316 async fn cache_get_record( 279 317 &self, 280 318 did: &str, ··· 362 400 pub use opake_core::storage::{ 363 401 resolve_handle_or_did, sanitize_did, AccountEntry, Config, Identity, 364 402 }; 403 + 404 + fn pair_state_dir(account_dir: &Path) -> PathBuf { 405 + account_dir.join("pair_states") 406 + } 407 + 408 + fn pair_state_filename(rkey: &str) -> String { 409 + // Rkeys are ATProto TIDs (base32, no dots/slashes) so passthrough is safe. 410 + // Belt-and-braces: strip any separators defensively. 411 + let safe = rkey.replace(['/', '\\', '.', ':'], "_"); 412 + format!("{safe}.bin") 413 + } 365 414 366 415 #[cfg(test)] 367 416 #[path = "config_tests.rs"]
+45
apps/cli/src/config_tests.rs
··· 343 343 & 0o777; 344 344 assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 345 345 } 346 + 347 + #[tokio::test] 348 + async fn pair_state_roundtrips() { 349 + let (_dir, storage) = test_storage(); 350 + let did = "did:plc:pair"; 351 + let rkey = "3krelfgabcdef"; 352 + let key = [7u8; 32]; 353 + 354 + storage.save_pair_state(did, rkey, &key).await.unwrap(); 355 + let loaded = storage.load_pair_state(did, rkey).await.unwrap(); 356 + assert_eq!(loaded.as_slice(), &key); 357 + 358 + storage.delete_pair_state(did, rkey).await.unwrap(); 359 + let missing = storage.load_pair_state(did, rkey).await; 360 + assert!(matches!( 361 + missing, 362 + Err(opake_core::error::Error::NotFound(_)) 363 + )); 364 + } 365 + 366 + #[tokio::test] 367 + async fn delete_pair_state_is_idempotent_when_missing() { 368 + let (_dir, storage) = test_storage(); 369 + // Deleting without a prior save returns Ok — avoids a race between the 370 + // cancel path and a successful completion that beat it. 371 + storage 372 + .delete_pair_state("did:plc:none", "nope") 373 + .await 374 + .unwrap(); 375 + } 376 + 377 + #[tokio::test] 378 + async fn pair_state_file_is_mode_0600() { 379 + let (_dir, storage) = test_storage(); 380 + let did = "did:plc:perm"; 381 + let rkey = "rk1"; 382 + storage.save_pair_state(did, rkey, &[0u8; 32]).await.unwrap(); 383 + 384 + let path = storage 385 + .account_dir(did) 386 + .join("pair_states") 387 + .join(format!("{rkey}.bin")); 388 + let mode = path.metadata().unwrap().permissions().mode() & 0o777; 389 + assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 390 + }
+1 -1
apps/web/src/components/cabinet/TopBar.tsx
··· 119 119 )} 120 120 <ul className="menu w-full p-1"> 121 121 {[ 122 - { icon: KeyIcon, label: "Encryption Keys", to: "/devices" as const }, 122 + { icon: KeyIcon, label: "Devices", to: "/devices" as const }, 123 123 { icon: GearIcon, label: "Settings", to: "/cabinet/settings" as const }, 124 124 ].map(({ icon: Icon, label, to }) => ( 125 125 <li key={label}>
+34 -49
apps/web/src/lib/pairing.ts
··· 1 - // Device pairing — thin wrappers over @opake/sdk pairing methods. 1 + // Device pairing — thin wrappers over @opake/sdk. 2 + // 3 + // New-device flow (`createPairRequest` / `awaitPairCompletion` / `cancel`) 4 + // lives entirely in the SDK as static methods that take Storage + DID — 5 + // it can't use `getOpake()` because there's no Opake handle yet on the 6 + // new device. The existing-device helpers below go through the handle. 2 7 3 - import { getOpake } from "@/stores/auth"; 8 + import { getOpake, useAuthStore } from "@/stores/auth"; 9 + import { getStorage } from "@/stores/auth"; 4 10 import { formatFingerprint } from "@/lib/encoding"; 5 - import { rkeyFromUri } from "@/lib/atUri"; 6 - import type { PairRequestResult, PairResponseRecord, Identity } from "@opake/sdk"; 11 + import { Opake } from "@opake/sdk"; 12 + import type { PairRequestResult, AwaitPairOptions } from "@opake/sdk"; 7 13 8 14 // --------------------------------------------------------------------------- 9 15 // Types (re-exported for UI consumption) ··· 16 22 readonly ephemeralKey: Uint8Array; 17 23 } 18 24 19 - export type { PairResponseRecord }; 25 + export type { PairRequestResult }; 20 26 21 27 // --------------------------------------------------------------------------- 22 - // Create pair request (new device) 28 + // New-device flow 23 29 // --------------------------------------------------------------------------- 24 30 31 + function requireActiveDid(): string { 32 + const s = useAuthStore.getState().session; 33 + if (s.status !== "active") throw new Error("no active session"); 34 + return s.did; 35 + } 36 + 25 37 export async function createPairRequest(): Promise<PairRequestResult> { 26 - return getOpake().createPairRequest(); 38 + const storage = await getStorage(); 39 + return Opake.createPairRequest(storage, requireActiveDid()); 40 + } 41 + 42 + export async function awaitPairCompletion( 43 + requestRkey: string, 44 + options?: AwaitPairOptions, 45 + ): Promise<void> { 46 + const storage = await getStorage(); 47 + return Opake.awaitPairCompletion(storage, requireActiveDid(), requestRkey, options); 48 + } 49 + 50 + export async function cancelPairRequest(requestRkey: string): Promise<void> { 51 + const storage = await getStorage(); 52 + return Opake.cancelPairRequest(storage, requireActiveDid(), requestRkey); 27 53 } 28 54 29 55 // --------------------------------------------------------------------------- 30 - // List pending pair requests (existing device) 56 + // Existing-device flow (uses the Opake handle) 31 57 // --------------------------------------------------------------------------- 32 58 33 59 export async function listPairRequests(maxAge: number): Promise<PendingPairRequest[]> { ··· 43 69 })); 44 70 } 45 71 46 - // --------------------------------------------------------------------------- 47 - // Poll for pair response (new device) 48 - // --------------------------------------------------------------------------- 49 - 50 - export async function pollForPairResponse( 51 - requestRkey: string, 52 - did: string, 53 - ): Promise<PairResponseRecord | null> { 54 - const responses = await getOpake().listPairResponses(); 55 - const requestUri = `at://${did}/app.opake.pairRequest/${requestRkey}`; 56 - const match = responses.find((r) => r.requestUri === requestUri); 57 - return match?.value ?? null; 58 - } 59 - 60 - // --------------------------------------------------------------------------- 61 - // Receive pair response (new device — SDK handles unwrap + decrypt) 62 - // --------------------------------------------------------------------------- 63 - 64 - export async function receivePairResponse( 65 - response: PairResponseRecord, 66 - ephemeralPrivKey: Uint8Array, 67 - ): Promise<Identity> { 68 - return getOpake().receivePairResponse(response, ephemeralPrivKey); 69 - } 70 - 71 - // --------------------------------------------------------------------------- 72 - // Approve pair request (existing device — SDK handles encrypt + wrap) 73 - // --------------------------------------------------------------------------- 74 - 75 72 export async function approvePairRequest( 76 73 requestUri: string, 77 74 ephemeralPubKey: Uint8Array, 78 75 ): Promise<void> { 79 76 await getOpake().approvePairRequest(requestUri, ephemeralPubKey); 80 77 } 81 - 82 - // --------------------------------------------------------------------------- 83 - // Cleanup (delete request + response records) 84 - // --------------------------------------------------------------------------- 85 - 86 - export async function cleanupPairRecords( 87 - requestUri: string, 88 - responseRkey: string | null, 89 - ): Promise<void> { 90 - const requestRkey = rkeyFromUri(requestUri); 91 - await getOpake().cleanupPairRecords(requestRkey, responseRkey ?? ""); 92 - }
+61 -85
apps/web/src/routes/devices/pair.request.lazy.tsx
··· 1 - import { useCallback, useEffect, useRef, useState } from "react"; 1 + import { useEffect, useRef, useState } from "react"; 2 2 import { createLazyFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { useAuthStore } from "@/stores/auth"; 4 4 import { formatFingerprint } from "@/lib/encoding"; 5 5 import { 6 + awaitPairCompletion, 7 + cancelPairRequest, 6 8 createPairRequest, 7 - pollForPairResponse, 8 - receivePairResponse, 9 - cleanupPairRecords, 10 9 } from "@/lib/pairing"; 11 10 import { CheckCircleIcon, WarningIcon } from "@phosphor-icons/react"; 12 11 import { useAppStore } from "@/stores/app"; 13 - import type { PairRequestResult } from "@opake/sdk"; 14 12 15 - const POLL_INTERVAL_MS = 3000; 16 - 17 - // Module-level promise dedup: WASM async methods hold RefCell<&mut self>, 18 - // so concurrent calls on the same context panic. StrictMode double-mounts 19 - // would fire two createPairRequest calls — this ensures only one runs. 20 - const pairInitState = { current: null as Promise<PairRequestResult> | null }; 21 - 22 - // --------------------------------------------------------------------------- 23 - // Page — new device requesting identity from an existing device 24 - // --------------------------------------------------------------------------- 13 + // Module-level dedup promise: StrictMode double-mounts would otherwise 14 + // fire two createPairRequest calls in rapid succession. Both would write 15 + // distinct pair-request records to the PDS and leave an orphan behind. 16 + const pairInitState = { 17 + current: null as Promise<{ rkey: string; fingerprint: string; uri: string }> | null, 18 + }; 25 19 26 20 type RequestState = 27 21 | { step: "generating" } 28 - | { step: "waiting"; fingerprint: string; requestUri: string } 22 + | { step: "waiting"; fingerprint: string; requestRkey: string; requestUri: string } 29 23 | { step: "receiving" } 30 24 | { step: "success" } 31 25 | { step: "error"; message: string }; ··· 33 27 function PairRequestPage() { 34 28 const navigate = useNavigate(); 35 29 const [state, setState] = useState<RequestState>({ step: "generating" }); 36 - const { addLoading, removeLoading, isLoading } = useAppStore(); 37 - const ephemeralPrivKeyRef = useRef<Uint8Array | null>(null); 38 - const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); 39 - 40 - const cleanup = useCallback(() => { 41 - if (pollRef.current) { 42 - clearInterval(pollRef.current); 43 - pollRef.current = null; 44 - } 45 - }, []); 30 + const { addLoading, removeLoading } = useAppStore(); 31 + const abortRef = useRef<AbortController | null>(null); 46 32 47 33 useEffect(() => { 48 - const cancelledRef = { current: false }; 34 + let cancelled = false; 35 + const controller = new AbortController(); 36 + abortRef.current = controller; 49 37 50 38 async function init() { 51 - const authState = useAuthStore.getState(); 52 - if (authState.session.status !== "active") return; 53 - 54 - const { did } = authState.session; 39 + if (useAuthStore.getState().session.status !== "active") return; 55 40 56 41 addLoading("pair-request-init"); 42 + let info: { rkey: string; fingerprint: string; uri: string } | null = null; 57 43 try { 58 - // Deduplicate: StrictMode double-mount shares one WASM call. 59 - pairInitState.current ??= createPairRequest(); 60 - const pairResult = await pairInitState.current; 61 - ephemeralPrivKeyRef.current = pairResult.ephemeralPrivateKey; 62 - 63 - if (cancelledRef.current) return; 64 - 65 - const fingerprint = formatFingerprint(pairResult.ephemeralPublicKey); 66 - setState({ step: "waiting", fingerprint, requestUri: pairResult.uri }); 67 - 68 - pollRef.current = setInterval(async () => { 69 - // Guard: WASM holds RefCell<&mut self> for async calls. Overlapping 70 - // polls would panic with "recursive use of an object detected." 71 - if (isLoading("pair-request-poll")) return; 72 - addLoading("pair-request-poll"); 73 - try { 74 - const response = await pollForPairResponse(pairResult.rkey, did); 75 - if (!response || cancelledRef.current) return; 76 - 77 - cleanup(); 78 - setState({ step: "receiving" }); 79 - addLoading("pair-request-receive"); 80 - 81 - const privKey = ephemeralPrivKeyRef.current; 82 - if (!privKey) { 83 - setState({ step: "error", message: "Ephemeral private key unavailable" }); 84 - removeLoading("pair-request-receive"); 85 - return; 86 - } 44 + pairInitState.current ??= createPairRequest().then((r) => ({ 45 + rkey: r.rkey, 46 + uri: r.uri, 47 + fingerprint: formatFingerprint(r.ephemeralPublicKey), 48 + })); 49 + info = await pairInitState.current; 87 50 88 - const identity = await receivePairResponse(response, privKey); 89 - await useAuthStore.getState().saveReceivedIdentity(identity); 90 - 91 - // Clean up PDS records (best-effort) 92 - await cleanupPairRecords(pairResult.uri, null).catch(Function.prototype as () => void); 93 - 94 - setState({ step: "success" }); 95 - removeLoading("pair-request-receive"); 96 - setTimeout(() => navigate({ to: "/cabinet" }), 1500); 97 - } catch (err) { 98 - console.error("[pairing] receive failed:", err); 99 - cleanup(); 100 - removeLoading("pair-request-receive"); 101 - setState({ 102 - step: "error", 103 - message: err instanceof Error ? err.message : String(err), 104 - }); 105 - } finally { 106 - removeLoading("pair-request-poll"); 107 - } 108 - }, POLL_INTERVAL_MS); 51 + if (cancelled) return; 52 + setState({ 53 + step: "waiting", 54 + fingerprint: info.fingerprint, 55 + requestRkey: info.rkey, 56 + requestUri: info.uri, 57 + }); 109 58 } catch (err) { 110 59 console.error("[pairing] init failed:", err); 111 - if (cancelledRef.current) return; 60 + if (cancelled) return; 112 61 setState({ 113 62 step: "error", 114 63 message: err instanceof Error ? err.message : String(err), 115 64 }); 65 + return; 116 66 } finally { 117 67 pairInitState.current = null; 118 68 removeLoading("pair-request-init"); 119 69 } 70 + 71 + try { 72 + await awaitPairCompletion(info.rkey, { signal: controller.signal }); 73 + if (cancelled) return; 74 + setState({ step: "receiving" }); 75 + await useAuthStore.getState().finalizePairing(); 76 + setState({ step: "success" }); 77 + setTimeout(() => navigate({ to: "/cabinet" }), 1500); 78 + } catch (err) { 79 + if (controller.signal.aborted) return; 80 + console.error("[pairing] completion failed:", err); 81 + setState({ 82 + step: "error", 83 + message: err instanceof Error ? err.message : String(err), 84 + }); 85 + } 120 86 } 121 87 122 88 void init(); 123 89 return () => { 124 - cancelledRef.current = true; 125 - cleanup(); 90 + cancelled = true; 91 + controller.abort(); 92 + abortRef.current = null; 93 + // Fire-and-forget: if the user walks away before the request was 94 + // created, there's nothing to cancel; if it was, we tear it down so 95 + // the PDS and storage don't retain orphan state. 96 + const s = useAuthStore.getState(); 97 + const rkey = 98 + state.step === "waiting" ? state.requestRkey : undefined; 99 + if (rkey && s.session.status === "active") { 100 + void cancelPairRequest(rkey).catch(() => {}); 101 + } 126 102 }; 127 - }, [cleanup, navigate, addLoading, removeLoading, isLoading]); 103 + }, [navigate, addLoading, removeLoading]); 128 104 129 105 return ( 130 106 <div className="flex w-full max-w-md flex-col items-center">
+11 -6
apps/web/src/stores/auth.ts
··· 91 91 // eslint-disable-next-line functional/no-let 92 92 let bootPromise: Promise<void> | null = null; 93 93 94 - async function getStorage(): Promise<IndexedDbStorage> { 94 + export async function getStorage(): Promise<IndexedDbStorage> { 95 95 if (!storage) { 96 96 // Request persistent storage BEFORE first IDB write. Without this, 97 97 // the browser may evict our identity keys under disk pressure, ··· 161 161 generateSeedPhrase(): Promise<string>; 162 162 validateSeedPhrase(phrase: string): Promise<boolean>; 163 163 saveIdentity(seedPhrase: string): Promise<void>; 164 - saveReceivedIdentity(identity: import("@opake/sdk").Identity): Promise<void>; 164 + /** 165 + * Called after `Opake.awaitPairCompletion` resolves. The identity is 166 + * already persisted inside WASM's storage write; this refreshes the 167 + * auth store so the rest of the app picks it up. 168 + */ 169 + finalizePairing(): Promise<void>; 165 170 publishPublicKey(): Promise<void>; 166 171 } 167 172 ··· 472 477 } 473 478 }, 474 479 475 - async saveReceivedIdentity(identity) { 480 + async finalizePairing() { 476 481 const did = requireActiveDid(); 477 - const done = loading("save-identity"); 482 + const done = loading("finalize-pairing"); 478 483 try { 479 484 const { Opake } = await loadSdk(); 480 485 const s = await getStorage(); 481 - await s.saveIdentity(did, identity); 482 - 486 + // `awaitPairCompletion` already wrote the identity to storage via 487 + // WASM — we just spin up an Opake handle and refresh store state. 483 488 const opake = await Opake.init({ storage: s, did }); 484 489 await seedIndexerUrl(opake); 485 490 opakeInstance?.destroy();
+1 -1
crates/opake-core/src/account_config_tests.rs
··· 62 62 crate::opake::Opake::new( 63 63 client, 64 64 "did:plc:test".into(), 65 - Some(identity), 65 + identity, 66 66 OsRng, 67 67 NoopStorage, 68 68 || 1_700_000_000_000_000,
+7
crates/opake-core/src/error.rs
··· 23 23 #[error("record not found: {0}")] 24 24 NotFound(String), 25 25 26 + /// This device is authenticated (session is present) but no encryption 27 + /// identity is persisted locally. Callers should route the user to 28 + /// recovery (seed phrase) or pairing (another device) to bootstrap one. 29 + /// Distinct from `NotFound` so the SDK/CLI can prompt for the right flow. 30 + #[error("no encryption identity for this device — recover from seed phrase or pair another device")] 31 + IdentityMissing, 32 + 26 33 /// The target handle or DID is a valid identity but has not published an 27 34 /// Opake public key yet (`app.opake.publicKey/self` is absent). Distinct 28 35 /// from `NotFound` (which covers handle-resolution failures) so callers
+1 -1
crates/opake-core/src/manager/download.rs
··· 53 53 } 54 54 FileContext::Workspace(ws) => { 55 55 let doc_authority = atproto::parse_at_uri(document_uri)?.authority; 56 - let private_key = self.opake.require_identity()?.private_key_bytes()?; 56 + let private_key = self.opake.identity().private_key_bytes()?; 57 57 58 58 if doc_authority == self.opake.did { 59 59 let (filename, plaintext) = documents::download_with_group_key(
+1 -1
crates/opake-core/src/manager/editor.rs
··· 100 100 match &self.context { 101 101 FileContext::Cabinet(cabinet) => Ok((cabinet.did.clone(), cabinet.private_key, None)), 102 102 FileContext::Workspace(ws) => { 103 - let private_key = *self.opake.require_identity()?.private_key_bytes()?; 103 + let private_key = *self.opake.identity().private_key_bytes()?; 104 104 Ok((self.opake.did.clone(), private_key, Some(ws.key.clone()))) 105 105 } 106 106 }
+3 -3
crates/opake-core/src/manager/manager_tests.rs
··· 79 79 let mut opake = Opake::new( 80 80 client, 81 81 DID.into(), 82 - Some(identity), 82 + identity, 83 83 OsRng, 84 84 NoopStorage, 85 85 || 1_700_000_000_000_000, ··· 167 167 let mut opake = Opake::new( 168 168 client, 169 169 DID.into(), 170 - Some(identity), 170 + identity, 171 171 OsRng, 172 172 NoopStorage, 173 173 || 1_700_000_000_000_000, ··· 242 242 let mut opake = Opake::new( 243 243 client, 244 244 ALICE_DID.into(), 245 - Some(identity), 245 + identity, 246 246 OsRng, 247 247 NoopStorage, 248 248 || 1_700_000_000_000_000,
+7 -9
crates/opake-core/src/manager/tree.rs
··· 392 392 cached: CachedCollection, 393 393 ) -> Result<(Vec<CachedRecord>, Vec<crate::indexer::TreeProposal>), Error> { 394 394 let indexer_url = self.opake.resolve_indexer_url(); 395 - let signing_key = match self.opake.require_identity() { 396 - Ok(id) => match id.signing_key_bytes() { 397 - Ok(Some(k)) => k, 398 - _ => return Ok((cached.records, Vec::new())), 399 - }, 400 - Err(_) => return Ok((cached.records, Vec::new())), 395 + let signing_key = match self.opake.identity().signing_key_bytes() { 396 + Ok(Some(k)) => k, 397 + _ => return Ok((cached.records, Vec::new())), 401 398 }; 402 399 403 400 // Extract sync cursor from the metadata sentinel record (uri = "__sync__") ··· 541 538 async fn bootstrap_tree(&mut self) -> Result<DirectoryTree, Error> { 542 539 let indexer_url = self.opake.resolve_indexer_url(); 543 540 544 - let identity = self.opake.require_identity()?; 545 - let signing_key = identity 541 + let signing_key = self 542 + .opake 543 + .identity() 546 544 .signing_key_bytes()? 547 545 .ok_or_else(|| Error::Auth("signing key required for Indexer sync".into()))?; 548 546 ··· 614 612 let mut group_keys = HashMap::new(); 615 613 group_keys.insert(ws.uri.clone(), ws.key.clone()); 616 614 617 - let private_key = self.opake.require_identity()?.private_key_bytes()?; 615 + let private_key = self.opake.identity().private_key_bytes()?; 618 616 tree.decrypt_names_with_group_keys(&self.opake.did, &private_key, &group_keys); 619 617 } 620 618 }
+83 -132
crates/opake-core/src/opake.rs
··· 37 37 pub struct Opake<T: Transport, R: CryptoRng + RngCore, S: Storage> { 38 38 pub(crate) client: XrpcClient<T>, 39 39 pub(crate) did: String, 40 - pub(crate) identity: Option<Identity>, 40 + pub(crate) identity: Identity, 41 41 pub(crate) rng: R, 42 42 pub(crate) storage: S, 43 43 /// Injected clock returning microseconds since Unix epoch. RFC 3339 ··· 64 64 None => "https://indexer.opake.app", 65 65 }; 66 66 67 + /// Build an authenticated XrpcClient for an already-logged-in account. 68 + /// 69 + /// Loads the PDS URL from Config and the session from Storage. Used by 70 + /// identity-less bootstrap flows (pairing) where `Opake::for_account` 71 + /// would otherwise fail with `Error::IdentityMissing`. 72 + pub async fn authenticated_client<T: Transport, S: Storage>( 73 + storage: &S, 74 + did: &str, 75 + transport: T, 76 + ) -> Result<XrpcClient<T>, Error> { 77 + let config = storage.load_config().await?; 78 + let account = config 79 + .accounts 80 + .get(did) 81 + .ok_or_else(|| Error::NotFound(format!("no account for {did}")))?; 82 + let session = storage.load_session(did).await?; 83 + Ok(XrpcClient::with_session( 84 + transport, 85 + account.pds_url.clone(), 86 + session, 87 + )) 88 + } 89 + 67 90 impl<T: Transport, R: CryptoRng + RngCore, S: Storage> Opake<T, R, S> { 68 91 pub fn new( 69 92 client: XrpcClient<T>, 70 93 did: String, 71 - identity: Option<Identity>, 94 + identity: Identity, 72 95 rng: R, 73 96 storage: S, 74 97 now_micros: fn() -> u64, ··· 90 113 /// Build an Opake for a specific account, reading from Storage. 91 114 /// 92 115 /// Loads config (to resolve DID + PDS URL), session (authentication), 93 - /// and identity (encryption keys, if present). If the identity exists 94 - /// but lacks signing keys, migrates it automatically. 116 + /// and identity (encryption keys). If the identity exists but lacks 117 + /// signing keys, migrates it automatically. 118 + /// 119 + /// Returns `Error::IdentityMissing` if the account is authenticated 120 + /// (session present) but has no encryption identity yet — callers 121 + /// should route the user to recovery (seed phrase) or pairing 122 + /// (another device) to bootstrap one, then retry. 95 123 /// 96 124 /// Indexer URL resolution (highest priority wins): 97 125 /// 1. Runtime env override — CLI checks `OPAKE_INDEXER_URL` after construction ··· 122 150 123 151 let session = storage.load_session(&target_did).await?; 124 152 125 - let identity = match storage.load_identity(&target_did).await { 126 - Ok(mut id) => { 127 - if id.ensure_signing_keys(&mut rng) { 128 - let _ = storage.save_identity(&target_did, &id).await; 129 - } 130 - Some(id) 131 - } 132 - Err(_) => None, 133 - }; 153 + let mut identity = storage 154 + .load_identity(&target_did) 155 + .await 156 + .map_err(|_| Error::IdentityMissing)?; 157 + if identity.ensure_signing_keys(&mut rng) { 158 + let _ = storage.save_identity(&target_did, &identity).await; 159 + } 134 160 135 161 let client = XrpcClient::with_session(transport, account.pds_url.clone(), session); 136 162 let mut opake = Self::new(client, target_did, identity, rng, storage, now_micros); ··· 177 203 Ok(FileContext::Workspace(ws)) 178 204 } 179 205 None => { 180 - let cabinet = Cabinet::from_identity(self.require_identity()?)?; 206 + let cabinet = Cabinet::from_identity(&self.identity)?; 181 207 Ok(FileContext::Cabinet(cabinet)) 182 208 } 183 209 } ··· 185 211 186 212 /// Build a cabinet FileContext. 187 213 pub fn cabinet_context(&self) -> Result<FileContext, Error> { 188 - let cabinet = Cabinet::from_identity(self.require_identity()?)?; 214 + let cabinet = Cabinet::from_identity(&self.identity)?; 189 215 Ok(FileContext::Cabinet(cabinet)) 190 216 } 191 217 ··· 238 264 /// A `workspace ls` immediately after `workspace create` may miss the 239 265 /// new entry until Jetstream delivers the commit. 240 266 pub async fn resolve_workspace(&mut self, name: &str) -> Result<Workspace, Error> { 241 - let identity = self.require_identity()?; 267 + let identity = &self.identity; 242 268 let private_key = identity.private_key_bytes()?; 243 269 244 270 let keyrings = self.discover_member_keyrings().await?; ··· 277 303 let at_uri = atproto::parse_at_uri(keyring_uri)?; 278 304 if at_uri.authority == self.did { 279 305 // Own keyring — fetch with authenticated client 280 - let identity = self.require_identity()?; 306 + let identity = &self.identity; 281 307 let private_key = identity.private_key_bytes()?; 282 308 let entry = self 283 309 .client ··· 307 333 /// endpoint), unwraps the group key, decrypts metadata. Used for 308 334 /// workspaces where the caller is a member but not the owner. 309 335 pub async fn resolve_foreign_workspace(&self, keyring_uri: &str) -> Result<Workspace, Error> { 310 - let identity = self.require_identity()?; 336 + let identity = &self.identity; 311 337 let private_key = identity.private_key_bytes()?; 312 338 let at_uri = crate::atproto::parse_at_uri(keyring_uri)?; 313 339 let owner_did = &at_uri.authority; ··· 356 382 &mut self.client 357 383 } 358 384 359 - /// The caller's identity, if one exists (may be absent pre-pairing). 360 - pub fn identity(&self) -> Option<&Identity> { 361 - self.identity.as_ref() 362 - } 363 - 364 - /// The caller's identity, or error if absent. 385 + /// The caller's encryption identity. 365 386 /// 366 - /// Use this for operations that need encryption keys. 367 - pub fn require_identity(&self) -> Result<&Identity, Error> { 368 - self.identity.as_ref().ok_or_else(|| { 369 - Error::NotFound("no identity — generate keys or pair this device first".into()) 370 - }) 387 + /// Always present — Opake requires an identity at construction time. 388 + /// Accounts that authenticate but haven't bootstrapped an identity yet 389 + /// (via recovery or pairing) produce `Error::IdentityMissing` from 390 + /// `for_account` rather than an `Opake` with a dangling handle. 391 + pub fn identity(&self) -> &Identity { 392 + &self.identity 371 393 } 372 394 373 395 /// Current RFC 3339 UTC timestamp (microsecond precision). ··· 441 463 name: &str, 442 464 description: Option<&str>, 443 465 ) -> Result<(String, ContentKey), Error> { 444 - let identity = self.require_identity()?; 466 + let identity = &self.identity; 445 467 let pubkey = identity.public_key_bytes()?; 446 468 let now = self.now(); 447 469 let result = keyrings::create_keyring( ··· 485 507 log::trace!("sync: discovering workspaces for {}", self.did); 486 508 let indexer_keyrings = self.discover_member_keyrings().await?; 487 509 log::trace!("sync: found {} workspaces", indexer_keyrings.len()); 488 - let identity = self.require_identity()?; 510 + let identity = &self.identity; 489 511 let private_key = identity.private_key_bytes()?; 490 512 491 513 let mut results = Vec::with_capacity(indexer_keyrings.len()); ··· 510 532 let target = indexer_keyrings.iter().find(|kr| kr.uri == keyring_uri); 511 533 let Some(kr) = target else { return Ok(None) }; 512 534 513 - let identity = self.require_identity()?; 535 + let identity = &self.identity; 514 536 let private_key = identity.private_key_bytes()?; 515 537 let result = self.sync_single_workspace(kr, &private_key).await; 516 538 self.auto_persist_session().await?; ··· 654 676 // Derive identity + group key once for all proposals that need key wrapping. 655 677 // Mutable because removeMember/leave rotates the key — subsequent proposals 656 678 // must use the rotated key. 657 - let identity = self.require_identity()?; 679 + let identity = &self.identity; 658 680 let private_key = identity.private_key_bytes()?; 659 681 let mut group_key = Self::unwrap_workspace_key(&keyring.members, &self.did, &private_key)?; 660 682 ··· 1378 1400 /// The grant and document live on the owner's PDS. All fetches are 1379 1401 /// unauthenticated public endpoint calls. Returns `(filename, plaintext)`. 1380 1402 pub async fn download_from_grant(&self, grant_uri: &str) -> Result<(String, Vec<u8>), Error> { 1381 - let private_key = self.require_identity()?.private_key_bytes()?; 1403 + let private_key = self.identity.private_key_bytes()?; 1382 1404 crate::documents::download_from_grant(self.client.transport(), &private_key, grant_uri) 1383 1405 .await 1384 1406 } ··· 1391 1413 &self, 1392 1414 grant_uri: &str, 1393 1415 ) -> Result<(String, crate::crypto::DocumentMetadata), Error> { 1394 - let private_key = self.require_identity()?.private_key_bytes()?; 1416 + let private_key = self.identity.private_key_bytes()?; 1395 1417 crate::documents::resolve_grant_metadata(self.client.transport(), &private_key, grant_uri) 1396 1418 .await 1397 1419 } ··· 1449 1471 /// The token is passed as a query parameter to the SSE endpoint, 1450 1472 /// sidestepping EventSource's inability to send custom headers. 1451 1473 pub async fn request_sse_token(&mut self) -> Result<String, Error> { 1452 - let identity = self.require_identity()?; 1453 - let signing_key = identity 1454 - .signing_key_bytes()? 1455 - .ok_or_else(|| Error::Auth("no signing key for SSE token request".into()))?; 1456 - 1474 + let signing_key = self.require_signing_key()?; 1457 1475 let url = self.resolve_indexer_url(); 1458 1476 crate::indexer::request_sse_token(self.client.transport(), &url, &self.did, &signing_key) 1459 1477 .await ··· 1462 1480 // -- Inbox (incoming grants via Indexer) -- 1463 1481 1464 1482 /// Fetch all incoming grants from the Indexer. 1465 - /// 1466 - /// Returns an empty list if no signing key exists. 1467 1483 pub async fn list_inbox(&mut self) -> Result<Vec<crate::indexer::InboxGrant>, Error> { 1468 - let identity = match self.identity.as_ref() { 1469 - Some(id) => id, 1470 - None => return Ok(vec![]), 1471 - }; 1472 - let signing_key = match identity.signing_key_bytes()? { 1473 - Some(k) => k, 1474 - None => return Ok(vec![]), 1475 - }; 1476 - 1484 + let signing_key = self.require_signing_key()?; 1477 1485 let url = self.resolve_indexer_url(); 1478 - 1479 1486 crate::indexer::fetch_inbox_all(self.client.transport(), &url, &self.did, &signing_key) 1480 1487 .await 1481 1488 } 1482 1489 1483 1490 /// Fetch workspace documents from the Indexer. 1484 - /// 1485 - /// Returns an empty list if no signing key exists. 1486 1491 pub async fn list_workspace_documents( 1487 1492 &mut self, 1488 1493 keyring_uri: &str, 1489 1494 ) -> Result<Vec<crate::indexer::WorkspaceDocument>, Error> { 1490 - let identity = match self.identity.as_ref() { 1491 - Some(id) => id, 1492 - None => return Ok(vec![]), 1493 - }; 1494 - let signing_key = match identity.signing_key_bytes()? { 1495 - Some(k) => k, 1496 - None => return Ok(vec![]), 1497 - }; 1498 - 1495 + let signing_key = self.require_signing_key()?; 1499 1496 let url = self.resolve_indexer_url(); 1500 - 1501 1497 crate::indexer::fetch_workspace_documents( 1502 1498 self.client.transport(), 1503 1499 &url, ··· 1509 1505 } 1510 1506 1511 1507 /// Fetch all keyrings the user is a member of, with full record data. 1512 - /// 1513 - /// Returns an empty list if no signing key exists. 1514 1508 pub async fn discover_member_keyrings( 1515 1509 &mut self, 1516 1510 ) -> Result<Vec<crate::indexer::IndexerKeyring>, Error> { 1517 - let identity = match self.identity.as_ref() { 1518 - Some(id) => id, 1519 - None => return Ok(vec![]), 1520 - }; 1521 - let signing_key = match identity.signing_key_bytes()? { 1522 - Some(k) => k, 1523 - None => return Ok(vec![]), 1524 - }; 1525 - 1511 + let signing_key = self.require_signing_key()?; 1526 1512 let url = self.resolve_indexer_url(); 1527 - 1528 1513 crate::indexer::fetch_member_keyrings( 1529 1514 self.client.transport(), 1530 1515 &url, ··· 1532 1517 &signing_key, 1533 1518 ) 1534 1519 .await 1520 + } 1521 + 1522 + /// Fetch the Ed25519 signing key for Indexer auth, or error if missing. 1523 + /// 1524 + /// Identity migration runs in `for_account`, so a freshly loaded Opake 1525 + /// always has signing keys. The only way to hit this error is a custom 1526 + /// `Opake::new` caller that supplied a legacy Identity without signing 1527 + /// keys — surface the problem rather than quietly returning empty data. 1528 + fn require_signing_key(&self) -> Result<crate::storage::Ed25519SecretKey, Error> { 1529 + self.identity 1530 + .signing_key_bytes()? 1531 + .ok_or_else(|| Error::Auth("identity is missing Ed25519 signing key".into())) 1535 1532 } 1536 1533 1537 1534 // -- Sharing (pending shares) -- ··· 1558 1555 &mut self, 1559 1556 resolver_transport: &impl Transport, 1560 1557 ) -> Result<crate::sharing::RetryResult, Error> { 1561 - let identity = self.require_identity()?; 1558 + let identity = &self.identity; 1562 1559 let private_key = identity.private_key_bytes()?; 1563 1560 let now = crate::client::time::unix_now(); 1564 1561 let pds_url = self.client.base_url().to_owned(); ··· 1648 1645 &mut self, 1649 1646 document_uri: &str, 1650 1647 ) -> Result<crate::documents::KeyringDownloadResult, Error> { 1651 - let identity = self.require_identity()?; 1648 + let identity = &self.identity; 1652 1649 let private_key = identity.private_key_bytes()?; 1653 1650 crate::documents::download_from_keyring_member( 1654 1651 self.client.transport(), ··· 1674 1671 1675 1672 /// Publish or update the caller's public key record on the PDS. 1676 1673 pub async fn publish_public_key(&mut self) -> Result<String, Error> { 1677 - let identity = self.require_identity()?; 1674 + let identity = &self.identity; 1678 1675 let pubkey = identity.public_key_bytes()?; 1679 1676 let signing_key = identity.verify_key_bytes()?; 1680 1677 let now = self.now(); ··· 1688 1685 self.signoff(result).await 1689 1686 } 1690 1687 1691 - // -- Pairing -- 1692 - 1693 - /// Create a pair request (new device side). Returns the record ref + ephemeral keypair. 1694 - pub async fn create_pair_request( 1695 - &mut self, 1696 - ) -> Result<(crate::client::RecordRef, crate::crypto::EphemeralKeypair), Error> { 1697 - let now = self.now(); 1698 - let result = 1699 - crate::pairing::create_pair_request(&mut self.client, &now, &mut self.rng).await; 1700 - self.signoff(result).await 1701 - } 1688 + // -- Pairing (existing-device side) -- 1689 + // 1690 + // The new-device side is identity-less and lives in `crate::pairing` 1691 + // as free functions — see that module for the full flow. 1702 1692 1703 1693 /// List pending pair requests on this account. 1704 1694 pub async fn list_pair_requests(&mut self) -> Result<Vec<crate::client::RecordEntry>, Error> { ··· 1710 1700 Ok(page.records) 1711 1701 } 1712 1702 1713 - /// List pair response records on this account. 1714 - pub async fn list_pair_responses(&mut self) -> Result<Vec<crate::client::RecordEntry>, Error> { 1715 - let result = self 1716 - .client 1717 - .list_records(crate::records::PAIR_RESPONSE_COLLECTION, Some(100), None) 1718 - .await; 1719 - let page = self.signoff(result).await?; 1720 - Ok(page.records) 1721 - } 1722 - 1723 - /// Approve a pair request (existing device side). 1703 + /// Approve a pair request by wrapping this device's identity to the 1704 + /// requester's ephemeral public key and publishing the response. 1724 1705 pub async fn approve_pair_request( 1725 1706 &mut self, 1726 1707 request_uri: &str, 1727 1708 ephemeral_public_key: &crate::crypto::X25519PublicKey, 1728 1709 ) -> Result<(), Error> { 1729 - let identity = self.identity.as_ref().ok_or_else(|| { 1730 - Error::NotFound("no identity — generate keys or pair this device first".into()) 1731 - })?; 1732 1710 let now = self.now(); 1733 1711 let result = crate::pairing::respond_to_pair_request( 1734 1712 &mut self.client, 1735 - identity, 1713 + &self.identity, 1736 1714 request_uri, 1737 1715 ephemeral_public_key, 1738 1716 &now, 1739 1717 &mut self.rng, 1740 1718 ) 1741 1719 .await; 1742 - self.signoff(result).await 1743 - } 1744 - 1745 - /// Receive a pair response and derive the identity (new device side). 1746 - pub async fn receive_pair_response( 1747 - &mut self, 1748 - response: &crate::records::PairResponse, 1749 - ephemeral_private_key: &crate::crypto::X25519PrivateKey, 1750 - ) -> Result<Identity, Error> { 1751 - crate::pairing::receive_pair_response( 1752 - &mut self.client, 1753 - &self.did, 1754 - response, 1755 - ephemeral_private_key, 1756 - ) 1757 - .await 1758 - } 1759 - 1760 - /// Clean up pair request and response records after successful pairing. 1761 - pub async fn cleanup_pair_records( 1762 - &mut self, 1763 - request_rkey: &str, 1764 - response_rkey: &str, 1765 - ) -> Result<(), Error> { 1766 - let result = 1767 - crate::pairing::cleanup_pair_records(&mut self.client, request_rkey, response_rkey) 1768 - .await; 1769 1720 self.signoff(result).await 1770 1721 } 1771 1722
+2 -2
crates/opake-core/src/opake_tests.rs
··· 20 20 Opake::new( 21 21 client, 22 22 "did:plc:test".into(), 23 - Some(identity), 23 + identity, 24 24 OsRng, 25 25 NoopStorage, 26 26 test_now_micros, ··· 149 149 Opake::new( 150 150 client, 151 151 OWNER_DID.into(), 152 - Some(identity), 152 + identity, 153 153 OsRng, 154 154 NoopStorage, 155 155 test_now_micros,
+35
crates/opake-core/src/pairing/cancel.rs
··· 1 + use crate::client::{Transport, XrpcClient}; 2 + use crate::error::Error; 3 + use crate::records::PAIR_REQUEST_COLLECTION; 4 + use crate::storage::Storage; 5 + 6 + /// Cancel an in-flight pair request initiated by this device. 7 + /// 8 + /// Used when the user backs out of the "waiting for response" state on the 9 + /// new device. Wipes the ephemeral private key from Storage and deletes the 10 + /// request record from the PDS. Safe to call when either side of that pair 11 + /// (storage entry / PDS record) is already missing — both operations are 12 + /// best-effort on NotFound. 13 + pub async fn cancel_pair_request<T, S>( 14 + client: &mut XrpcClient<T>, 15 + storage: &S, 16 + did: &str, 17 + request_rkey: &str, 18 + ) -> Result<(), Error> 19 + where 20 + T: Transport, 21 + S: Storage, 22 + { 23 + let _ = storage.delete_pair_state(did, request_rkey).await; 24 + match client 25 + .delete_record(PAIR_REQUEST_COLLECTION, request_rkey) 26 + .await 27 + { 28 + Ok(()) => Ok(()), 29 + // Not-found on the PDS is a tolerated race: the request may have 30 + // already been cleaned up by the expiry sweep or by a successful 31 + // completion that raced with this cancel. 32 + Err(Error::NotFound(_)) => Ok(()), 33 + Err(e) => Err(e), 34 + } 35 + }
+9 -2
crates/opake-core/src/pairing/mod.rs
··· 8 8 // an ephemeral public key, the existing device wraps the identity to that 9 9 // key, and writes the encrypted payload as a record. All records are deleted 10 10 // after the transfer completes. 11 + // 12 + // Key containment: the ephemeral *private* half never crosses the WASM/JS 13 + // boundary. `create_pair_request` writes it to Storage; `try_complete_pair` 14 + // reads it back when a matching response arrives. JS/TS code only sees the 15 + // request uri, rkey, and public-key fingerprint — never raw key bytes. 11 16 17 + mod cancel; 12 18 mod cleanup; 13 19 mod receive; 14 20 mod request; 15 21 mod respond; 16 22 23 + pub use cancel::cancel_pair_request; 17 24 pub use cleanup::{ 18 25 cleanup_expired_pair_requests, cleanup_pair_records, CleanupResult, 19 26 DEFAULT_PAIR_REQUEST_TTL_SECONDS, 20 27 }; 21 - pub use receive::receive_pair_response; 22 - pub use request::create_pair_request; 28 + pub use receive::{complete_pair_response, try_complete_pair}; 29 + pub use request::{create_pair_request, PairRequestInfo}; 23 30 pub use respond::respond_to_pair_request;
+102 -7
crates/opake-core/src/pairing/receive.rs
··· 1 1 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 2 2 3 + use crate::atproto; 3 4 use crate::client::{Transport, XrpcClient}; 4 5 use crate::crypto::{decrypt_blob, unwrap_key, EncryptedPayload, X25519PrivateKey}; 5 6 use crate::error::Error; 6 - use crate::records::{PairResponse, PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; 7 - use crate::storage::Identity; 7 + use crate::records::{ 8 + PairResponse, PublicKeyRecord, PAIR_RESPONSE_COLLECTION, PUBLIC_KEY_COLLECTION, 9 + PUBLIC_KEY_RKEY, 10 + }; 11 + use crate::storage::{Identity, Storage}; 12 + 13 + use super::cleanup::cleanup_pair_records; 14 + 15 + /// Poll once for a pair response matching `request_rkey`. 16 + /// 17 + /// Returns `true` if a matching response was found, decrypted, and the 18 + /// resulting Identity persisted to Storage. Returns `false` if no response 19 + /// has arrived yet — the caller's outer loop should sleep and try again. 20 + /// 21 + /// On success this function also deletes both the request and the matching 22 + /// response records from the PDS, and wipes the ephemeral pair state from 23 + /// Storage. After it returns `true`, the caller can invoke the normal 24 + /// `Opake::for_account` path and identity-requiring operations will work. 25 + pub async fn try_complete_pair<T, S>( 26 + client: &mut XrpcClient<T>, 27 + storage: &S, 28 + did: &str, 29 + request_rkey: &str, 30 + ) -> Result<bool, Error> 31 + where 32 + T: Transport, 33 + S: Storage, 34 + { 35 + let request_uri = format!("at://{did}/{}/{request_rkey}", crate::records::PAIR_REQUEST_COLLECTION); 36 + 37 + let page = client 38 + .list_records(PAIR_RESPONSE_COLLECTION, Some(100), None) 39 + .await?; 40 + 41 + let Some((response, response_rkey)) = find_matching_response(&page.records, &request_uri)? 42 + else { 43 + return Ok(false); 44 + }; 45 + 46 + complete_pair_response(client, storage, did, request_rkey, &response, &response_rkey).await?; 47 + Ok(true) 48 + } 49 + 50 + /// Consume a specific pair response: decrypt the Identity, persist it, and 51 + /// tear down both the on-PDS records and the local ephemeral key state. 52 + /// 53 + /// Public for cases where a caller has already inspected a response (e.g. 54 + /// the test suite) — the normal flow is `try_complete_pair`, which calls 55 + /// this internally. 56 + pub async fn complete_pair_response<T, S>( 57 + client: &mut XrpcClient<T>, 58 + storage: &S, 59 + did: &str, 60 + request_rkey: &str, 61 + response: &PairResponse, 62 + response_rkey: &str, 63 + ) -> Result<(), Error> 64 + where 65 + T: Transport, 66 + S: Storage, 67 + { 68 + let privkey_bytes = storage.load_pair_state(did, request_rkey).await?; 69 + let privkey: X25519PrivateKey = privkey_bytes.as_slice().try_into().map_err(|_| { 70 + Error::InvalidRecord(format!( 71 + "pair state for {request_rkey} has wrong length: expected 32 bytes, got {}", 72 + privkey_bytes.len() 73 + )) 74 + })?; 75 + 76 + let identity = decrypt_pair_response(client, did, response, &privkey).await?; 77 + storage.save_identity(did, &identity).await?; 78 + 79 + // Tear-down is best-effort from the caller's perspective — the Identity 80 + // is saved, so the user is paired. A partial cleanup failure leaves 81 + // orphan records that `cleanup_expired_pair_requests` will sweep later. 82 + let _ = storage.delete_pair_state(did, request_rkey).await; 83 + let _ = cleanup_pair_records(client, request_rkey, response_rkey).await; 8 84 9 - /// Receive and decrypt an identity from a pairing response. 85 + Ok(()) 86 + } 87 + 88 + fn find_matching_response( 89 + entries: &[crate::client::RecordEntry], 90 + request_uri: &str, 91 + ) -> Result<Option<(PairResponse, String)>, Error> { 92 + for entry in entries { 93 + let Ok(response) = serde_json::from_value::<PairResponse>(entry.value.clone()) else { 94 + continue; 95 + }; 96 + if response.request == request_uri { 97 + let rkey = atproto::parse_at_uri(&entry.uri)?.rkey; 98 + return Ok(Some((response, rkey))); 99 + } 100 + } 101 + Ok(None) 102 + } 103 + 104 + /// Decrypt a pair response to recover the sender's Identity. 10 105 /// 11 - /// Decrypts the identity payload using the ephemeral private key, then 12 - /// verifies the decrypted public key matches the one published on the PDS. 13 - pub async fn receive_pair_response( 106 + /// Verifies the decrypted public key against the sender's published 107 + /// `publicKey/self` record so an attacker who can intercept PDS writes 108 + /// cannot swap in a different identity. 109 + async fn decrypt_pair_response( 14 110 client: &mut XrpcClient<impl Transport>, 15 111 did: &str, 16 112 response: &PairResponse, ··· 40 136 )) 41 137 })?; 42 138 43 - // Verify: the decrypted public key must match the published publicKey/self record. 44 139 let record_entry = client 45 140 .get_record(did, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY) 46 141 .await?;
+48 -9
crates/opake-core/src/pairing/request.rs
··· 1 - use crate::client::{RecordRef, Transport, XrpcClient}; 2 - use crate::crypto::{generate_ephemeral_keypair, CryptoRng, EphemeralKeypair, RngCore}; 1 + use crate::atproto; 2 + use crate::client::{Transport, XrpcClient}; 3 + use crate::crypto::{generate_ephemeral_keypair, CryptoRng, RngCore, X25519PublicKey}; 3 4 use crate::error::Error; 4 5 use crate::records::{PairRequest, PAIR_REQUEST_COLLECTION}; 6 + use crate::storage::Storage; 5 7 6 - /// Create a pairing request on the PDS and return the record URI + ephemeral keypair. 8 + /// Public-facing result of `create_pair_request`. The ephemeral *private* key 9 + /// is intentionally absent — it has been persisted to Storage and will be 10 + /// loaded back automatically during `try_complete_pair`. The *public* key is 11 + /// returned so callers can display a fingerprint for out-of-band comparison. 12 + #[derive(Debug)] 13 + pub struct PairRequestInfo { 14 + pub uri: String, 15 + pub rkey: String, 16 + pub ephemeral_public_key: X25519PublicKey, 17 + } 18 + 19 + /// Create a pair request on the caller's PDS and persist the ephemeral 20 + /// private key to local Storage. 7 21 /// 8 - /// The caller holds the ephemeral private key in memory while polling for a response. 9 - pub async fn create_pair_request( 10 - client: &mut XrpcClient<impl Transport>, 22 + /// The private half of the ephemeral keypair is needed again when the 23 + /// paired device's response arrives (minutes to days later). Rather than 24 + /// return it for the caller to stash somewhere, we write it to Storage 25 + /// under `(did, rkey)` so it never leaves the crypto-owning layer. 26 + pub async fn create_pair_request<T, R, S>( 27 + client: &mut XrpcClient<T>, 28 + storage: &S, 29 + did: &str, 11 30 created_at: &str, 12 - rng: &mut (impl CryptoRng + RngCore), 13 - ) -> Result<(RecordRef, EphemeralKeypair), Error> { 31 + rng: &mut R, 32 + ) -> Result<PairRequestInfo, Error> 33 + where 34 + T: Transport, 35 + R: CryptoRng + RngCore, 36 + S: Storage, 37 + { 14 38 let keypair = generate_ephemeral_keypair(rng); 39 + 15 40 let record = PairRequest::new(&keypair.public_key, created_at); 16 41 let record_ref = client 17 42 .create_record(PAIR_REQUEST_COLLECTION, &record) 18 43 .await?; 19 - Ok((record_ref, keypair)) 44 + 45 + let rkey = atproto::parse_at_uri(&record_ref.uri)?.rkey; 46 + storage 47 + .save_pair_state(did, &rkey, &keypair.private_key) 48 + .await?; 49 + 50 + Ok(PairRequestInfo { 51 + uri: record_ref.uri, 52 + rkey, 53 + ephemeral_public_key: keypair.public_key, 54 + }) 20 55 } 56 + 57 + #[cfg(test)] 58 + #[path = "request_tests.rs"] 59 + mod tests;
+156
crates/opake-core/src/pairing/request_tests.rs
··· 1 + use std::collections::HashMap; 2 + use std::sync::Mutex; 3 + 4 + use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 5 + use crate::crypto::OsRng; 6 + use crate::error::Error; 7 + use crate::records::PAIR_REQUEST_COLLECTION; 8 + use crate::storage::{CachedCollection, CachedRecord, Config, Identity, Storage}; 9 + use crate::test_utils::MockTransport; 10 + 11 + use super::create_pair_request; 12 + 13 + const TEST_DID: &str = "did:plc:newdevice"; 14 + 15 + fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 16 + let session = Session::Legacy(LegacySession { 17 + did: TEST_DID.into(), 18 + handle: "newdevice.test".into(), 19 + access_jwt: "test-jwt".into(), 20 + refresh_jwt: "test-refresh".into(), 21 + }); 22 + XrpcClient::with_session(mock, "https://pds.test".into(), session) 23 + } 24 + 25 + fn create_record_response(rkey: &str) -> HttpResponse { 26 + HttpResponse { 27 + status: 200, 28 + headers: vec![], 29 + body: serde_json::to_vec(&serde_json::json!({ 30 + "uri": format!("at://{TEST_DID}/{PAIR_REQUEST_COLLECTION}/{rkey}"), 31 + "cid": "bafytest", 32 + })) 33 + .unwrap(), 34 + } 35 + } 36 + 37 + #[derive(Default)] 38 + struct MemoryPairStore { 39 + entries: Mutex<HashMap<(String, String), Vec<u8>>>, 40 + } 41 + 42 + impl Storage for MemoryPairStore { 43 + async fn load_config(&self) -> Result<Config, Error> { 44 + Err(Error::NotFound("memory".into())) 45 + } 46 + async fn save_config(&self, _: &Config) -> Result<(), Error> { 47 + Ok(()) 48 + } 49 + async fn load_identity(&self, _: &str) -> Result<Identity, Error> { 50 + Err(Error::NotFound("memory".into())) 51 + } 52 + async fn save_identity(&self, _: &str, _: &Identity) -> Result<(), Error> { 53 + Ok(()) 54 + } 55 + async fn load_session(&self, _: &str) -> Result<Session, Error> { 56 + Err(Error::NotFound("memory".into())) 57 + } 58 + async fn save_session(&self, _: &str, _: &Session) -> Result<(), Error> { 59 + Ok(()) 60 + } 61 + async fn remove_account(&self, _: &str) -> Result<(), Error> { 62 + Ok(()) 63 + } 64 + async fn save_pair_state( 65 + &self, 66 + did: &str, 67 + rkey: &str, 68 + private_key: &[u8], 69 + ) -> Result<(), Error> { 70 + self.entries 71 + .lock() 72 + .unwrap() 73 + .insert((did.into(), rkey.into()), private_key.to_vec()); 74 + Ok(()) 75 + } 76 + async fn load_pair_state(&self, did: &str, rkey: &str) -> Result<Vec<u8>, Error> { 77 + self.entries 78 + .lock() 79 + .unwrap() 80 + .get(&(did.into(), rkey.into())) 81 + .cloned() 82 + .ok_or_else(|| Error::NotFound(format!("pair state {rkey}"))) 83 + } 84 + async fn delete_pair_state(&self, did: &str, rkey: &str) -> Result<(), Error> { 85 + self.entries 86 + .lock() 87 + .unwrap() 88 + .remove(&(did.into(), rkey.into())); 89 + Ok(()) 90 + } 91 + async fn cache_get_record( 92 + &self, 93 + _: &str, 94 + _: &str, 95 + _: &str, 96 + ) -> Result<Option<CachedRecord>, Error> { 97 + Ok(None) 98 + } 99 + async fn cache_put_records(&self, _: &str, _: &str, _: &[CachedRecord]) -> Result<(), Error> { 100 + Ok(()) 101 + } 102 + async fn cache_remove_record(&self, _: &str, _: &str, _: &str) -> Result<(), Error> { 103 + Ok(()) 104 + } 105 + async fn cache_get_collection( 106 + &self, 107 + _: &str, 108 + _: &str, 109 + ) -> Result<Option<CachedCollection>, Error> { 110 + Ok(None) 111 + } 112 + async fn cache_put_collection( 113 + &self, 114 + _: &str, 115 + _: &str, 116 + _: &CachedCollection, 117 + ) -> Result<(), Error> { 118 + Ok(()) 119 + } 120 + async fn cache_invalidate_collection(&self, _: &str, _: &str) -> Result<(), Error> { 121 + Ok(()) 122 + } 123 + async fn cache_clear(&self, _: &str) -> Result<(), Error> { 124 + Ok(()) 125 + } 126 + } 127 + 128 + #[tokio::test] 129 + async fn create_pair_request_persists_ephemeral_privkey() { 130 + let mock = MockTransport::new(); 131 + mock.enqueue(create_record_response("rk1")); 132 + 133 + let storage = MemoryPairStore::default(); 134 + let mut client = mock_client(mock); 135 + let mut rng = OsRng; 136 + 137 + let info = create_pair_request(&mut client, &storage, TEST_DID, "2026-04-01T00:00:00Z", &mut rng) 138 + .await 139 + .unwrap(); 140 + 141 + assert_eq!(info.rkey, "rk1"); 142 + assert_eq!(info.uri, format!("at://{TEST_DID}/{PAIR_REQUEST_COLLECTION}/rk1")); 143 + assert_eq!(info.ephemeral_public_key.len(), 32); 144 + 145 + let stored = storage 146 + .load_pair_state(TEST_DID, "rk1") 147 + .await 148 + .expect("private key should be persisted"); 149 + assert_eq!(stored.len(), 32); 150 + // The returned public key is derived from the persisted private key — 151 + // verify the DH relationship explicitly so a storage roundtrip bug would 152 + // surface here instead of silently at pair completion. 153 + let priv_bytes: [u8; 32] = stored.as_slice().try_into().unwrap(); 154 + let derived = x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(priv_bytes)); 155 + assert_eq!(derived.as_bytes(), &info.ephemeral_public_key); 156 + }
+45
crates/opake-core/src/storage.rs
··· 296 296 297 297 fn remove_account(&self, did: &str) -> impl std::future::Future<Output = Result<(), Error>>; 298 298 299 + // -- Pair state (ephemeral private key during device pairing) ------------ 300 + // 301 + // The new device generates an X25519 ephemeral keypair for each pair 302 + // request. The private half must survive between `create_pair_request` 303 + // and `try_complete_pair` — which can be minutes to days apart — so it 304 + // is persisted here. These bytes never cross the WASM/JS boundary: WASM 305 + // writes them via the storage adapter, reads them back the same way, 306 + // and wipes them once pairing succeeds or the request is cancelled. 307 + 308 + /// Persist the ephemeral private key for a pending pair request. 309 + fn save_pair_state( 310 + &self, 311 + did: &str, 312 + rkey: &str, 313 + private_key: &[u8], 314 + ) -> impl std::future::Future<Output = Result<(), Error>>; 315 + 316 + /// Load the ephemeral private key for a pending pair request. 317 + fn load_pair_state( 318 + &self, 319 + did: &str, 320 + rkey: &str, 321 + ) -> impl std::future::Future<Output = Result<Vec<u8>, Error>>; 322 + 323 + /// Delete the ephemeral private key for a pair request (on completion or cancellation). 324 + fn delete_pair_state( 325 + &self, 326 + did: &str, 327 + rkey: &str, 328 + ) -> impl std::future::Future<Output = Result<(), Error>>; 329 + 299 330 // -- Cache: record-level ------------------------------------------------- 300 331 301 332 /// Look up a single cached record by URI. ··· 383 414 Ok(()) 384 415 } 385 416 async fn remove_account(&self, _did: &str) -> Result<(), Error> { 417 + Ok(()) 418 + } 419 + async fn save_pair_state( 420 + &self, 421 + _did: &str, 422 + _rkey: &str, 423 + _private_key: &[u8], 424 + ) -> Result<(), Error> { 425 + Ok(()) 426 + } 427 + async fn load_pair_state(&self, _did: &str, _rkey: &str) -> Result<Vec<u8>, Error> { 428 + Err(Error::NotFound("NoopStorage".into())) 429 + } 430 + async fn delete_pair_state(&self, _did: &str, _rkey: &str) -> Result<(), Error> { 386 431 Ok(()) 387 432 } 388 433 async fn cache_get_record(
+1 -2
crates/opake-wasm/src/file_manager_wasm.rs
··· 429 429 pub async fn get_document_metadata(&self, document_uri: &str) -> Result<JsValue, JsError> { 430 430 let (mut opake, ctx) = self.parts().await?; 431 431 let did = opake.did().to_owned(); 432 - let identity = opake.require_identity().map_err(wasm_err)?; 433 - let private_key = identity.private_key_bytes().map_err(wasm_err)?; 432 + let private_key = opake.identity().private_key_bytes().map_err(wasm_err)?; 434 433 let group_key = match ctx { 435 434 FileContext::Workspace(ref ws) => Some(ws.key.clone()), 436 435 FileContext::Cabinet(_) => None,
+40
crates/opake-wasm/src/js_storage.rs
··· 52 52 #[wasm_bindgen(method, js_name = removeAccount)] 53 53 fn remove_account_js(this: &JsStorageAdapter, did: &str) -> Promise; 54 54 55 + #[wasm_bindgen(method, js_name = savePairState)] 56 + fn save_pair_state_js( 57 + this: &JsStorageAdapter, 58 + did: &str, 59 + rkey: &str, 60 + private_key: &[u8], 61 + ) -> Promise; 62 + 63 + #[wasm_bindgen(method, js_name = loadPairState)] 64 + fn load_pair_state_js(this: &JsStorageAdapter, did: &str, rkey: &str) -> Promise; 65 + 66 + #[wasm_bindgen(method, js_name = deletePairState)] 67 + fn delete_pair_state_js(this: &JsStorageAdapter, did: &str, rkey: &str) -> Promise; 68 + 55 69 #[wasm_bindgen(method, js_name = cacheGetCollection)] 56 70 fn cache_get_collection_js(this: &JsStorageAdapter, did: &str, collection: &str) -> Promise; 57 71 ··· 149 163 150 164 async fn remove_account(&self, did: &str) -> Result<(), Error> { 151 165 resolve(&self.adapter.remove_account_js(did)).await?; 166 + Ok(()) 167 + } 168 + 169 + // -- Pair state: bridged to JS-side IndexedDB ------------------------------ 170 + 171 + async fn save_pair_state( 172 + &self, 173 + did: &str, 174 + rkey: &str, 175 + private_key: &[u8], 176 + ) -> Result<(), Error> { 177 + resolve(&self.adapter.save_pair_state_js(did, rkey, private_key)).await?; 178 + Ok(()) 179 + } 180 + 181 + async fn load_pair_state(&self, did: &str, rkey: &str) -> Result<Vec<u8>, Error> { 182 + let val = resolve(&self.adapter.load_pair_state_js(did, rkey)).await?; 183 + if val.is_null() || val.is_undefined() { 184 + return Err(Error::NotFound(format!("pair state {rkey}"))); 185 + } 186 + // Raw Uint8Array, not a serde shape — bypass `from_js` and copy bytes. 187 + Ok(js_sys::Uint8Array::new(&val).to_vec()) 188 + } 189 + 190 + async fn delete_pair_state(&self, did: &str, rkey: &str) -> Result<(), Error> { 191 + resolve(&self.adapter.delete_pair_state_js(did, rkey)).await?; 152 192 Ok(()) 153 193 } 154 194
+2
crates/opake-wasm/src/lib.rs
··· 23 23 #[cfg(target_arch = "wasm32")] 24 24 mod opake_wasm; 25 25 #[cfg(target_arch = "wasm32")] 26 + mod pair_wasm; 27 + #[cfg(target_arch = "wasm32")] 26 28 mod sse_wasm; 27 29 #[cfg(target_arch = "wasm32")] 28 30 pub(crate) mod wasm_util;
+4 -74
crates/opake-wasm/src/opake_wasm.rs
··· 201 201 #[wasm_bindgen(js_name = listWorkspaces)] 202 202 pub async fn list_workspaces(&self) -> Result<JsValue, JsError> { 203 203 let mut opake = self.opake().await?; 204 - let identity = opake.require_identity().map_err(wasm_err)?; 205 - let private_key = identity.private_key_bytes().map_err(wasm_err)?; 204 + let private_key = opake.identity().private_key_bytes().map_err(wasm_err)?; 206 205 let did = opake.did().to_string(); 207 206 208 207 let keyrings = opake ··· 458 457 }) 459 458 } 460 459 461 - /// Create a pair request (new device side). Returns { uri, rkey, ephemeralPublicKey, ephemeralPrivateKey }. 462 - #[wasm_bindgen(js_name = createPairRequest)] 463 - pub async fn create_pair_request(&self) -> Result<JsValue, JsError> { 464 - let mut opake = self.opake().await?; 465 - let (record_ref, keypair) = opake.create_pair_request().await.map_err(wasm_err)?; 466 - 467 - #[derive(Serialize)] 468 - struct R { 469 - uri: String, 470 - rkey: String, 471 - #[serde(with = "crate::wasm_util::serde_bytes")] 472 - ephemeral_public_key: Vec<u8>, 473 - #[serde(with = "crate::wasm_util::serde_bytes")] 474 - ephemeral_private_key: Vec<u8>, 475 - } 476 - 477 - let rkey = opake_core::atproto::parse_at_uri(&record_ref.uri) 478 - .map(|u| u.rkey) 479 - .unwrap_or_default(); 480 - 481 - to_js(&R { 482 - uri: record_ref.uri, 483 - rkey, 484 - ephemeral_public_key: keypair.public_key.to_vec(), 485 - ephemeral_private_key: keypair.private_key.to_vec(), 486 - }) 487 - } 488 - 489 - /// Approve a pair request (existing device side). Creates the pair response record. 460 + /// Approve a pair request from an already-authenticated device. Wraps 461 + /// this device's identity to the requester's ephemeral public key and 462 + /// publishes the response record. 490 463 #[wasm_bindgen(js_name = approvePairRequest)] 491 464 pub async fn approve_pair_request( 492 465 &self, ··· 501 474 .map_err(wasm_err) 502 475 } 503 476 504 - /// Receive a pair response (new device side). Returns the derived Identity. 505 - #[wasm_bindgen(js_name = receivePairResponse)] 506 - pub async fn receive_pair_response( 507 - &self, 508 - response_js: JsValue, 509 - ephemeral_private_key: &[u8], 510 - ) -> Result<JsValue, JsError> { 511 - let response: opake_core::records::PairResponse = 512 - serde_wasm_bindgen::from_value(response_js) 513 - .map_err(|e| JsError::new(&e.to_string()))?; 514 - let privkey: opake_core::crypto::X25519PrivateKey = ephemeral_private_key 515 - .try_into() 516 - .map_err(|_| JsError::new("ephemeral private key must be 32 bytes"))?; 517 - let mut opake = self.opake().await?; 518 - let identity = opake 519 - .receive_pair_response(&response, &privkey) 520 - .await 521 - .map_err(wasm_err)?; 522 - to_js(&identity) 523 - } 524 - 525 477 /// Sync a single workspace by keyring URI. Returns null if the URI is not 526 478 /// in the member list, or the sync result otherwise. 527 479 #[wasm_bindgen(js_name = syncWorkspaceByUri)] ··· 715 667 let mut opake = self.opake().await?; 716 668 let entries = opake.list_pair_requests().await.map_err(wasm_err)?; 717 669 to_js(&entries) 718 - } 719 - 720 - /// List pair responses on this account. 721 - #[wasm_bindgen(js_name = listPairResponses)] 722 - pub async fn list_pair_responses(&self) -> Result<JsValue, JsError> { 723 - let mut opake = self.opake().await?; 724 - let entries = opake.list_pair_responses().await.map_err(wasm_err)?; 725 - to_js(&entries) 726 - } 727 - 728 - /// Clean up pair request + response records after successful pairing. 729 - #[wasm_bindgen(js_name = cleanupPairRecords)] 730 - pub async fn cleanup_pair_records( 731 - &self, 732 - request_rkey: &str, 733 - response_rkey: &str, 734 - ) -> Result<(), JsError> { 735 - let mut opake = self.opake().await?; 736 - opake 737 - .cleanup_pair_records(request_rkey, response_rkey) 738 - .await 739 - .map_err(wasm_err) 740 670 } 741 671 742 672 /// Delete all expired pair requests and orphaned responses (daemon use).
+123
crates/opake-wasm/src/pair_wasm.rs
··· 1 + // New-device pair flow bindings (identity-less). 2 + // 3 + // These functions don't require a constructed OpakeContext — the new 4 + // device has a session but no encryption identity until pairing completes. 5 + // The ephemeral private key stays inside WASM: `createPairRequest` persists 6 + // it to the caller's Storage adapter, `tryCompletePair` reads it back. JS 7 + // only ever sees `{ uri, rkey, ephemeralPublicKey }` going out and `true` 8 + // coming back on completion. 9 + 10 + use opake_core::client::{Transport, XrpcClient, WasmTransport}; 11 + use opake_core::crypto::OsRng; 12 + use opake_core::error::Error; 13 + use opake_core::opake::authenticated_client; 14 + use opake_core::storage::Storage; 15 + use serde::Serialize; 16 + use wasm_bindgen::prelude::*; 17 + 18 + use crate::js_storage::{JsStorage, JsStorageAdapter}; 19 + use crate::wasm_util::{to_js, wasm_err}; 20 + 21 + /// Create a pair request on the caller's PDS. 22 + /// 23 + /// Writes the ephemeral private key to the supplied Storage — it never 24 + /// crosses back into JS. Callers display the returned public key 25 + /// fingerprint on the new device for out-of-band comparison, then poll 26 + /// `tryCompletePair` until the paired device approves. 27 + #[wasm_bindgen(js_name = createPairRequest)] 28 + pub async fn create_pair_request_js( 29 + did: String, 30 + storage_adapter: JsStorageAdapter, 31 + ) -> Result<JsValue, JsError> { 32 + let storage = JsStorage::new(storage_adapter); 33 + let mut client = authenticated_client(&storage, &did, WasmTransport::new()) 34 + .await 35 + .map_err(wasm_err)?; 36 + 37 + let now = opake_core::timestamp::rfc3339_from_micros(crate::now_micros()); 38 + let mut rng = OsRng; 39 + let info = opake_core::pairing::create_pair_request(&mut client, &storage, &did, &now, &mut rng) 40 + .await 41 + .map_err(wasm_err)?; 42 + 43 + persist_if_refreshed(&storage, &did, &client).await?; 44 + 45 + // Snake_case on the wire matches the rest of the WASM surface; the SDK 46 + // transforms to camelCase via `pairRequestResultSchema`. 47 + #[derive(Serialize)] 48 + struct Dto { 49 + uri: String, 50 + rkey: String, 51 + #[serde(with = "crate::wasm_util::serde_bytes")] 52 + ephemeral_public_key: Vec<u8>, 53 + } 54 + 55 + to_js(&Dto { 56 + uri: info.uri, 57 + rkey: info.rkey, 58 + ephemeral_public_key: info.ephemeral_public_key.to_vec(), 59 + }) 60 + } 61 + 62 + /// Poll once for a pair response matching `request_rkey`. 63 + /// 64 + /// Returns `true` if a response was found and pairing completed — the 65 + /// Identity is now persisted to Storage and `Opake.init` will succeed. 66 + /// Returns `false` if nothing matched yet; the SDK's `awaitPairCompletion` 67 + /// wraps this in a `setTimeout` loop. 68 + #[wasm_bindgen(js_name = tryCompletePair)] 69 + pub async fn try_complete_pair_js( 70 + did: String, 71 + storage_adapter: JsStorageAdapter, 72 + request_rkey: String, 73 + ) -> Result<bool, JsError> { 74 + let storage = JsStorage::new(storage_adapter); 75 + let mut client = authenticated_client(&storage, &did, WasmTransport::new()) 76 + .await 77 + .map_err(wasm_err)?; 78 + 79 + let result = 80 + opake_core::pairing::try_complete_pair(&mut client, &storage, &did, &request_rkey) 81 + .await 82 + .map_err(wasm_err)?; 83 + 84 + persist_if_refreshed(&storage, &did, &client).await?; 85 + Ok(result) 86 + } 87 + 88 + /// Cancel an in-flight pair request. Wipes the ephemeral key from Storage 89 + /// and removes the request record from the PDS (tolerant of either side 90 + /// already being gone). 91 + #[wasm_bindgen(js_name = cancelPairRequest)] 92 + pub async fn cancel_pair_request_js( 93 + did: String, 94 + storage_adapter: JsStorageAdapter, 95 + request_rkey: String, 96 + ) -> Result<(), JsError> { 97 + let storage = JsStorage::new(storage_adapter); 98 + let mut client = authenticated_client(&storage, &did, WasmTransport::new()) 99 + .await 100 + .map_err(wasm_err)?; 101 + 102 + opake_core::pairing::cancel_pair_request(&mut client, &storage, &did, &request_rkey) 103 + .await 104 + .map_err(wasm_err)?; 105 + 106 + persist_if_refreshed(&storage, &did, &client).await 107 + } 108 + 109 + async fn persist_if_refreshed<T: Transport>( 110 + storage: &JsStorage, 111 + did: &str, 112 + client: &XrpcClient<T>, 113 + ) -> Result<(), JsError> { 114 + if client.session_refreshed() { 115 + if let Some(session) = client.session() { 116 + storage 117 + .save_session(did, session) 118 + .await 119 + .map_err(|e: Error| wasm_err(e))?; 120 + } 121 + } 122 + Ok(()) 123 + }
+3 -13
crates/opake-wasm/src/sse_wasm.rs
··· 270 270 match scope { 271 271 TreeInstall::Cabinet => { 272 272 let guard = self.opake.lock().await; 273 - let identity = guard 274 - .identity() 275 - .ok_or_else(|| JsError::new("no identity"))?; 276 - let private_key = *identity.private_key_bytes().map_err(wasm_err)?; 273 + let private_key = *guard.identity().private_key_bytes().map_err(wasm_err)?; 277 274 drop(guard); 278 275 279 276 let mut keeper = self.tree_keeper.lock().await; ··· 494 491 Box::pin(async move { 495 492 let guard = opake_rc.lock().await; 496 493 let did = guard.did().to_string(); 497 - let identity = guard 494 + let signing_key = guard 498 495 .identity() 499 - .ok_or_else(|| opake_core::error::Error::Sse("no identity".into()))?; 500 - // Ed25519 signing key — used for indexer auth signatures. 501 - let signing_key = identity 502 496 .signing_key_bytes() 503 497 .map_err(|e| opake_core::error::Error::Sse(format!("{e}")))? 504 498 .ok_or_else(|| { ··· 632 626 let maybe_entry = { 633 627 let guard = opake_rc.lock().await; 634 628 let did = guard.did().to_string(); 635 - let Some(identity) = guard.identity() else { 636 - log::warn!("[sse] workspace upsert: no identity"); 637 - return; 638 - }; 639 - let private_key = match identity.private_key_bytes() { 629 + let private_key = match guard.identity().private_key_bytes() { 640 630 Ok(pk) => pk, 641 631 Err(e) => { 642 632 log::warn!("[sse] workspace upsert: private_key_bytes failed: {e}");
+1
crates/opake-wasm/src/wasm_util.rs
··· 19 19 Error::Xrpc { .. } => "Xrpc", 20 20 Error::Indexer { .. } => "Indexer", 21 21 Error::NotFound(_) => "NotFound", 22 + Error::IdentityMissing => "IdentityMissing", 22 23 Error::RecipientNotReady(_) => "RecipientNotReady", 23 24 Error::AmbiguousName { .. } => "AmbiguousName", 24 25 Error::AlreadyExists(_) => "AlreadyExists",
+3 -1
packages/opake-sdk/src/index.ts
··· 57 57 type WorkspaceSyncResult, 58 58 type PairRequestResult, 59 59 type PendingPairRequest, 60 - type PairResponseRecord, 60 + } from "./types"; 61 + export type { AwaitPairOptions } from "./pairing"; 62 + export { 61 63 type InvitationEntry, 62 64 type TaskDef, 63 65 type GrantEntry,
+49 -35
packages/opake-sdk/src/opake.ts
··· 48 48 import { registerCleanup, unregisterCleanup } from "./finalizer"; 49 49 import { 50 50 createPairRequest as pairingCreate, 51 + awaitPairCompletion as pairingAwait, 52 + cancelPairRequest as pairingCancel, 51 53 listPairRequests as pairingList, 52 - listPairResponses as pairingListResponses, 53 54 approvePairRequest as pairingApprove, 54 - receivePairResponse as pairingReceive, 55 - cleanupPairRecords as pairingCleanup, 56 55 cleanupExpiredPairRequests as pairingCleanupExpired, 56 + type AwaitPairOptions, 57 57 } from "./pairing"; 58 58 59 59 // The WASM module types. We import dynamically after init. ··· 1058 1058 } 1059 1059 1060 1060 // --------------------------------------------------------------------------- 1061 - // Device pairing (implementations in ./pairing.ts) 1061 + // Device pairing 1062 1062 // --------------------------------------------------------------------------- 1063 + // 1064 + // The new-device side (create request + await completion) is exposed as 1065 + // static methods — they don't require an Opake instance because the new 1066 + // device has no Identity yet. See the `Opake.createPairRequest` / 1067 + // `Opake.awaitPairCompletion` pair below. 1068 + // 1069 + // The existing-device side stays on the instance: it already has an 1070 + // Identity and the authenticated context to wrap it against the 1071 + // requesting device's ephemeral public key. 1063 1072 1064 - /** Create a pair request (new device). Returns the record URI + ephemeral keypair. */ 1065 - @wrapWasmErrors 1066 - @withTokenGuard 1067 - createPairRequest(): Promise<import("./types").PairRequestResult> { 1068 - return pairingCreate(this.requireContext()); 1073 + /** 1074 + * Start a device-pairing request from the new device. 1075 + * 1076 + * Must run after `Opake.startLogin` / `Opake.completeLogin` have put an 1077 + * authenticated session in Storage but before `Opake.init` — which would 1078 + * fail with `IdentityMissing` at this stage. The returned fingerprint 1079 + * (first bytes of `ephemeralPublicKey`) is for out-of-band comparison 1080 + * with the approving device. 1081 + */ 1082 + static async createPairRequest( 1083 + storage: import("./storage").Storage, 1084 + did: string, 1085 + ): Promise<import("./types").PairRequestResult> { 1086 + return pairingCreate(storage, did); 1087 + } 1088 + 1089 + /** 1090 + * Poll until the paired device approves. Resolves once the received 1091 + * identity has been persisted to Storage; `Opake.init` then succeeds. 1092 + */ 1093 + static async awaitPairCompletion( 1094 + storage: import("./storage").Storage, 1095 + did: string, 1096 + requestRkey: string, 1097 + options?: AwaitPairOptions, 1098 + ): Promise<void> { 1099 + return pairingAwait(storage, did, requestRkey, options); 1100 + } 1101 + 1102 + /** Cancel an in-flight pair request. Wipes pair state on both sides. */ 1103 + static async cancelPairRequest( 1104 + storage: import("./storage").Storage, 1105 + did: string, 1106 + requestRkey: string, 1107 + ): Promise<void> { 1108 + return pairingCancel(storage, did, requestRkey); 1069 1109 } 1070 1110 1071 1111 /** List pending pair requests on this account. */ ··· 1075 1115 return pairingList(this.requireContext()); 1076 1116 } 1077 1117 1078 - /** List pair responses on this account. */ 1079 - @wrapWasmErrors 1080 - @withTokenGuard 1081 - listPairResponses(): Promise< 1082 - readonly { uri: string; requestUri: string; value: import("./types").PairResponseRecord }[] 1083 - > { 1084 - return pairingListResponses(this.requireContext()); 1085 - } 1086 - 1087 1118 /** Approve a pair request (existing device). Encrypts and sends the identity. */ 1088 1119 @wrapWasmErrors 1089 1120 @withTokenGuard 1090 1121 approvePairRequest(requestUri: string, ephemeralPublicKey: Uint8Array): Promise<void> { 1091 1122 return pairingApprove(this.requireContext(), requestUri, ephemeralPublicKey); 1092 - } 1093 - 1094 - /** Receive a pair response (new device). Decrypts the identity from the approving device. */ 1095 - @wrapWasmErrors 1096 - @withTokenGuard 1097 - receivePairResponse( 1098 - response: import("./types").PairResponseRecord, 1099 - ephemeralPrivateKey: Uint8Array, 1100 - ): Promise<import("./storage").Identity> { 1101 - return pairingReceive(this.requireContext(), response, ephemeralPrivateKey); 1102 - } 1103 - 1104 - /** Clean up pair request + response records after successful pairing. */ 1105 - @wrapWasmErrors 1106 - @withTokenGuard 1107 - cleanupPairRecords(requestRkey: string, responseRkey: string): Promise<void> { 1108 - return pairingCleanup(this.requireContext(), requestRkey, responseRkey); 1109 1123 } 1110 1124 1111 1125 /** Delete expired pair requests and orphaned responses. */
+90 -41
packages/opake-sdk/src/pairing.ts
··· 1 - // Device pairing — extracted from Opake class to keep the file manageable. 1 + // Device pairing — typed wrappers around the WASM bindings. 2 + // 3 + // Split by device role: 4 + // 5 + // - **New device** (no identity yet): standalone functions that take the 6 + // caller's Storage directly. `createPairRequest` persists the ephemeral 7 + // private key inside WASM; `awaitPairCompletion` polls until the paired 8 + // device responds, decrypts the identity, and writes it to Storage. 9 + // JS never sees the private key or the Identity DTO. 2 10 // 3 - // All functions take a WASM context and return typed results. 4 - // The Opake class delegates to these after token guard + context check. 11 + // - **Existing device** (has identity, holds an Opake handle): instance 12 + // methods on `Opake` that wrap the WASM handle's corresponding methods. 5 13 6 - import type { Identity } from "./storage"; 7 - import type { PendingPairRequest, PairResponseRecord, PairRequestResult } from "./types"; 14 + import type { Storage } from "./storage"; 15 + import type { PendingPairRequest, PairRequestResult } from "./types"; 8 16 import { pairRequestResultSchema } from "./schemas"; 17 + import { createStorageAdapter } from "./storage-adapter"; 18 + import { initWasm } from "./wasm"; 9 19 10 20 type Ctx = import("../wasm/opake.js").OpakeContext; 11 21 ··· 17 27 return bytes; 18 28 } 19 29 20 - export function createPairRequest(ctx: Ctx): Promise<PairRequestResult> { 21 - return ctx.createPairRequest().then(pairRequestResultSchema.parse); 30 + // --------------------------------------------------------------------------- 31 + // New-device flow (identity-less — takes Storage directly) 32 + // --------------------------------------------------------------------------- 33 + 34 + /** Options for {@link awaitPairCompletion}. */ 35 + export interface AwaitPairOptions { 36 + /** Milliseconds between polls (default 3000). */ 37 + readonly pollIntervalMs?: number; 38 + /** Abort polling after this many ms (default: none). */ 39 + readonly timeoutMs?: number; 40 + /** Optional cancellation signal. Aborting rejects the promise. */ 41 + readonly signal?: AbortSignal; 42 + } 43 + 44 + export async function createPairRequest( 45 + storage: Storage, 46 + did: string, 47 + ): Promise<PairRequestResult> { 48 + const wasm = await initWasm(); 49 + const adapter = createStorageAdapter(storage); 50 + const result = await wasm.createPairRequest(did, adapter); 51 + return pairRequestResultSchema.parse(result); 52 + } 53 + 54 + export async function awaitPairCompletion( 55 + storage: Storage, 56 + did: string, 57 + requestRkey: string, 58 + options: AwaitPairOptions = {}, 59 + ): Promise<void> { 60 + const wasm = await initWasm(); 61 + const interval = options.pollIntervalMs ?? 3000; 62 + const deadline = 63 + options.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs; 64 + 65 + while (true) { 66 + if (options.signal?.aborted) { 67 + throw new DOMException("pairing cancelled", "AbortError"); 68 + } 69 + 70 + const adapter = createStorageAdapter(storage); 71 + const done = await wasm.tryCompletePair(did, adapter, requestRkey); 72 + if (done) return; 73 + 74 + if (deadline !== undefined && Date.now() >= deadline) { 75 + throw new Error("pairing timed out before the other device responded"); 76 + } 77 + 78 + // Honour a pending abort promptly rather than sleeping through it. 79 + await new Promise<void>((resolve, reject) => { 80 + const timer = setTimeout(resolve, interval); 81 + options.signal?.addEventListener( 82 + "abort", 83 + () => { 84 + clearTimeout(timer); 85 + reject(new DOMException("pairing cancelled", "AbortError")); 86 + }, 87 + { once: true }, 88 + ); 89 + }); 90 + } 91 + } 92 + 93 + export async function cancelPairRequest( 94 + storage: Storage, 95 + did: string, 96 + requestRkey: string, 97 + ): Promise<void> { 98 + const wasm = await initWasm(); 99 + const adapter = createStorageAdapter(storage); 100 + await wasm.cancelPairRequest(did, adapter, requestRkey); 22 101 } 102 + 103 + // --------------------------------------------------------------------------- 104 + // Existing-device flow (delegates through an Opake handle) 105 + // --------------------------------------------------------------------------- 23 106 24 107 export function listPairRequests(ctx: Ctx): Promise<readonly PendingPairRequest[]> { 25 108 return ctx.listPairRequests().then( ··· 37 120 ); 38 121 } 39 122 40 - export function listPairResponses( 41 - ctx: Ctx, 42 - ): Promise<readonly { uri: string; requestUri: string; value: PairResponseRecord }[]> { 43 - return ctx.listPairResponses().then( 44 - ( 45 - entries: readonly { 46 - uri: string; 47 - value: { request: string; [key: string]: unknown }; 48 - }[], 49 - ) => 50 - entries.map((e) => ({ 51 - uri: e.uri, 52 - requestUri: e.value.request, 53 - value: e.value as PairResponseRecord, 54 - })), 55 - ); 56 - } 57 - 58 123 export function approvePairRequest( 59 124 ctx: Ctx, 60 125 requestUri: string, 61 126 ephemeralPublicKey: Uint8Array, 62 127 ): Promise<void> { 63 128 return ctx.approvePairRequest(requestUri, ephemeralPublicKey); 64 - } 65 - 66 - export function receivePairResponse( 67 - ctx: Ctx, 68 - response: PairResponseRecord, 69 - ephemeralPrivateKey: Uint8Array, 70 - ): Promise<Identity> { 71 - return ctx.receivePairResponse(response, ephemeralPrivateKey) as Promise<Identity>; 72 - } 73 - 74 - export function cleanupPairRecords( 75 - ctx: Ctx, 76 - requestRkey: string, 77 - responseRkey: string, 78 - ): Promise<void> { 79 - return ctx.cleanupPairRecords(requestRkey, responseRkey); 80 129 } 81 130 82 131 export function cleanupExpiredPairRequests(ctx: Ctx): Promise<number> {
-2
packages/opake-sdk/src/schemas.ts
··· 321 321 uri: z.string(), 322 322 rkey: z.string(), 323 323 ephemeral_public_key: uint8Array, 324 - ephemeral_private_key: uint8Array, 325 324 }) 326 325 .transform((r) => ({ 327 326 uri: r.uri, 328 327 rkey: r.rkey, 329 328 ephemeralPublicKey: r.ephemeral_public_key, 330 - ephemeralPrivateKey: r.ephemeral_private_key, 331 329 })); 332 330 333 331 export type PairRequestResult = z.output<typeof pairRequestResultSchema>;
+6
packages/opake-sdk/src/storage-adapter.ts
··· 24 24 loadSession(did: string): Promise<Session>; 25 25 saveSession(did: string, session: Session): Promise<void>; 26 26 removeAccount(did: string): Promise<void>; 27 + savePairState(did: string, rkey: string, privateKey: Uint8Array): Promise<void>; 28 + loadPairState(did: string, rkey: string): Promise<Uint8Array>; 29 + deletePairState(did: string, rkey: string): Promise<void>; 27 30 cacheGetRecord(did: string, collection: string, uri: string): Promise<CachedRecord | null>; 28 31 cachePutRecords(did: string, collection: string, records: readonly CachedRecord[]): Promise<void>; 29 32 cacheRemoveRecord(did: string, collection: string, uri: string): Promise<void>; ··· 45 48 loadSession: (did) => storage.loadSession(did), 46 49 saveSession: (did, session) => storage.saveSession(did, session), 47 50 removeAccount: (did) => storage.removeAccount(did), 51 + savePairState: (did, rkey, privateKey) => storage.savePairState(did, rkey, privateKey), 52 + loadPairState: (did, rkey) => storage.loadPairState(did, rkey), 53 + deletePairState: (did, rkey) => storage.deletePairState(did, rkey), 48 54 cacheGetRecord: (did, collection, uri) => storage.cacheGetRecord(did, collection, uri), 49 55 cachePutRecords: (did, collection, records) => 50 56 storage.cachePutRecords(did, collection, records),
+15
packages/opake-sdk/src/storage.ts
··· 132 132 /** Remove all data for an account (identity, session, cache). */ 133 133 removeAccount(did: string): Promise<void>; 134 134 135 + // -- Pair state (ephemeral private key during device pairing) -------------- 136 + // 137 + // The new device persists its ephemeral X25519 private key here while 138 + // waiting for the old device to approve. WASM writes and reads these 139 + // bytes directly through the adapter — they never become a Uint8Array 140 + // in app code. Storage implementations should treat this material the 141 + // same as an Identity: persist durably, never log, never transmit. 142 + 143 + /** Persist the ephemeral private key for a pending pair request. */ 144 + savePairState(did: string, rkey: string, privateKey: Uint8Array): Promise<void>; 145 + /** Load the ephemeral private key for a pending pair request. */ 146 + loadPairState(did: string, rkey: string): Promise<Uint8Array>; 147 + /** Delete the ephemeral private key (on completion or cancellation). */ 148 + deletePairState(did: string, rkey: string): Promise<void>; 149 + 135 150 // -- Cache: record-level --------------------------------------------------- 136 151 137 152 /** Look up a single cached record by URI. */
+38
packages/opake-sdk/src/storage/indexeddb.ts
··· 50 50 fetchedAt: number; 51 51 } 52 52 53 + interface PairStateRow { 54 + did: string; 55 + rkey: string; 56 + privateKey: Uint8Array; 57 + } 58 + 53 59 // --------------------------------------------------------------------------- 54 60 // Database 55 61 // --------------------------------------------------------------------------- ··· 58 64 readonly configs!: Readonly<EntityTable<ConfigRow, "key">>; 59 65 readonly identities!: Readonly<EntityTable<IdentityRow, "did">>; 60 66 readonly sessions!: Readonly<EntityTable<SessionRow, "did">>; 67 + readonly pairStates!: Readonly<Table<PairStateRow>>; 61 68 readonly cacheRecords!: Readonly<Table<CacheRecordRow>>; 62 69 readonly cacheMeta!: Readonly<Table<CacheMetaRow>>; 63 70 ··· 67 74 configs: "key", 68 75 identities: "did", 69 76 sessions: "did", 77 + cacheRecords: "[did+collection+uri], [did+collection], did", 78 + cacheMeta: "[did+collection], did", 79 + }); 80 + // v2 adds the pair_states table for WASM-owned ephemeral pair keys. 81 + // Existing databases upgrade in-place; no data migration required. 82 + this.version(2).stores({ 83 + configs: "key", 84 + identities: "did", 85 + sessions: "did", 86 + pairStates: "[did+rkey], did", 70 87 cacheRecords: "[did+collection+uri], [did+collection], did", 71 88 cacheMeta: "[did+collection], did", 72 89 }); ··· 141 158 await this.db.sessions.delete(key); 142 159 } 143 160 161 + // -- Pair state ----------------------------------------------------------- 162 + 163 + async savePairState(did: string, rkey: string, privateKey: Uint8Array): Promise<void> { 164 + const key = sanitizeDid(did); 165 + await this.db.pairStates.put({ did: key, rkey, privateKey }); 166 + } 167 + 168 + async loadPairState(did: string, rkey: string): Promise<Uint8Array> { 169 + const key = sanitizeDid(did); 170 + const row = await this.db.pairStates.get([key, rkey]); 171 + if (!row) throw new StorageError(`no pair state for ${did}/${rkey}`); 172 + return row.privateKey; 173 + } 174 + 175 + async deletePairState(did: string, rkey: string): Promise<void> { 176 + const key = sanitizeDid(did); 177 + await this.db.pairStates.delete([key, rkey]); 178 + } 179 + 144 180 // -- Cache: record-level -------------------------------------------------- 145 181 146 182 async cacheGetRecord<T>( ··· 244 280 this.db.configs, 245 281 this.db.identities, 246 282 this.db.sessions, 283 + this.db.pairStates, 247 284 this.db.cacheRecords, 248 285 this.db.cacheMeta, 249 286 ], ··· 251 288 await this.db.configs.put({ key: CONFIG_KEY, value: updatedConfig }); 252 289 await this.db.identities.delete(key); 253 290 await this.db.sessions.delete(key); 291 + await this.db.pairStates.where("did").equals(key).delete(); 254 292 await this.db.cacheRecords.where("did").equals(did).delete(); 255 293 await this.db.cacheMeta.where("did").equals(did).delete(); 256 294 },
+27
packages/opake-sdk/src/storage/memory.ts
··· 37 37 private config: Config = { accounts: {} }; 38 38 private readonly identities = new Map<string, Identity>(); 39 39 private readonly sessions = new Map<string, Session>(); 40 + private readonly pairStates = new Map<string, Uint8Array>(); 40 41 private readonly cacheRecords = new Map<string, Map<string, CachedRecord>>(); 41 42 private readonly cacheMeta = new Map<string, number>(); 42 43 ··· 72 73 this.sessions.delete(did); 73 74 } 74 75 76 + // -- Pair state ------------------------------------------------------------ 77 + 78 + private pairStateKey(did: string, rkey: string): string { 79 + return `${did}::${rkey}`; 80 + } 81 + 82 + async savePairState(did: string, rkey: string, privateKey: Uint8Array): Promise<void> { 83 + // Copy the buffer so later mutations by the caller don't affect stored state. 84 + this.pairStates.set(this.pairStateKey(did, rkey), new Uint8Array(privateKey)); 85 + } 86 + 87 + async loadPairState(did: string, rkey: string): Promise<Uint8Array> { 88 + const key = this.pairStateKey(did, rkey); 89 + const bytes = this.pairStates.get(key); 90 + if (!bytes) throw new StorageError(`No pair state for ${did}/${rkey}`); 91 + return new Uint8Array(bytes); 92 + } 93 + 94 + async deletePairState(did: string, rkey: string): Promise<void> { 95 + this.pairStates.delete(this.pairStateKey(did, rkey)); 96 + } 97 + 75 98 async removeAccount(did: string): Promise<void> { 76 99 const remainingAccounts = Object.fromEntries( 77 100 Object.entries(this.config.accounts).filter(([key]) => key !== did), ··· 89 112 }; 90 113 this.identities.delete(did); 91 114 this.sessions.delete(did); 115 + const pairPrefix = `${did}::`; 116 + for (const key of [...this.pairStates.keys()]) { 117 + if (key.startsWith(pairPrefix)) this.pairStates.delete(key); 118 + } 92 119 await this.cacheClear(did); 93 120 } 94 121
+5 -5
packages/opake-sdk/src/types.ts
··· 149 149 // Device pairing 150 150 // --------------------------------------------------------------------------- 151 151 152 - /** Result of creating a pair request (new device side). */ 152 + /** Result of creating a pair request on the new device. 153 + * 154 + * `ephemeralPublicKey` is for fingerprint display only — the matching 155 + * private key stays inside WASM storage and is consumed automatically 156 + * by `awaitPairCompletion`. */ 153 157 export interface PairRequestResult { 154 158 readonly uri: string; 155 159 readonly rkey: string; 156 160 readonly ephemeralPublicKey: Uint8Array; 157 - readonly ephemeralPrivateKey: Uint8Array; 158 161 } 159 162 160 163 /** A pending pair request visible to the approving device. */ ··· 163 166 readonly ephemeralKey: Uint8Array; 164 167 readonly createdAt: string; 165 168 } 166 - 167 - /** Raw pair response record — opaque to consumers, passed to receivePairResponse. */ 168 - export type PairResponseRecord = Record<string, unknown>; 169 169 170 170 // --------------------------------------------------------------------------- 171 171 // Sharing