An encrypted personal cloud built on the AT Protocol.

Add device-to-device key pairing via PDS relay

When logging in on a new device, the existing encryption identity needs
to transfer securely. Both devices share the same DID and PDS repo, so
we use ephemeral X25519 DH to wrap the identity in transit — the PDS
only ever sees ciphertext.

Protocol: new device publishes an ephemeral public key as a pairRequest
record, existing device wraps the identity to that key and writes a
pairResponse. After transfer, both records are deleted.

Login now detects an existing publicKey/self on the PDS and skips
identity generation on second devices, directing users to `opake pair`
instead of silently overwriting the published key.

sans-self.org 37b5e52d 376f8af3

Waiting for spindle ...
+1096 -2
+11
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Add device-to-device key pairing via PDS [#183](https://issues.opake.app/issues/183.html) 16 + - Wire authenticated API layer for PDS and AppView [#174](https://issues.opake.app/issues/174.html) 17 + - Rewrite web auth store as discriminated union state machine [#172](https://issues.opake.app/issues/172.html) 18 + - Add OAuth client infrastructure to web frontend [#171](https://issues.opake.app/issues/171.html) 19 + - Refactor cabinet components to use daisyUI semantic classes [#166](https://issues.opake.app/issues/166.html) 20 + - Add web identity resolution [#152](https://issues.opake.app/issues/152.html) 21 + - Add web login and account switching [#144](https://issues.opake.app/issues/144.html) 22 + - Cache URI → name mappings for DirectoryTree resolution [#155](https://issues.opake.app/issues/155.html) 23 + - Add tree command to display document hierarchy [#90](https://issues.opake.app/issues/90.html) 15 24 - Add handle-based login with automatic PDS resolution [#182](https://issues.opake.app/issues/182.html) 16 25 - Implement web login flow with AT Protocol OAuth [#167](https://issues.opake.app/issues/167.html) 17 26 - Add AT Protocol OAuth (DPoP) for CLI authentication [#175](https://issues.opake.app/issues/175.html) ··· 55 64 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 56 65 57 66 ### Changed 67 + - Add browser key storage with IndexedDB and Web Crypto API [#160](https://issues.opake.app/issues/160.html) 68 + - Add inbox command for grant discovery via AppView [#162](https://issues.opake.app/issues/162.html) 58 69 - Port Figma Make cabinet design into web frontend [#165](https://issues.opake.app/issues/165.html) 59 70 - Amend web scaffold into WASM commit [#164](https://issues.opake.app/issues/164.html) 60 71 - Update blackbox tests and docs for new commands [#159](https://issues.opake.app/issues/159.html)
+1
CONTRIBUTING.md
··· 26 26 - encryption/decryption (AES-256-GCM, x25519 key wrapping) 27 27 - XRPC client with automatic token refresh 28 28 - document operations (upload, download, list, delete, resolve) 29 + - device pairing (ephemeral DH key exchange, identity transfer) 29 30 - AT Protocol record types and lexicon constants 30 31 - Storage trait + config/identity/session types (storage.rs) 31 32 - shared config path resolution (paths.rs)
+32
Makefile
··· 1 + # Opake — build targets 2 + # 3 + # Frontend targets source nvm to ensure the correct Node version (.nvmrc). 4 + 5 + SHELL := /bin/bash 6 + 7 + NVM = source "$${NVM_DIR:-$$HOME/.nvm}/nvm.sh" && nvm use --silent 8 + 9 + WASM_CRATE = crates/opake-wasm 10 + WASM_OUT = web/src/wasm/opake-wasm 11 + 12 + .PHONY: build wasm wasm-dev install web-build 13 + 14 + ## Build all Rust crates 15 + build: 16 + cargo build 17 + 18 + ## Build WASM (release) 19 + wasm: 20 + wasm-pack build $(WASM_CRATE) --target web --out-dir ../../$(WASM_OUT) --out-name opake 21 + 22 + ## Build WASM (debug, faster iteration) 23 + wasm-dev: 24 + wasm-pack build $(WASM_CRATE) --target web --dev --out-dir ../../$(WASM_OUT) --out-name opake 25 + 26 + ## Install frontend dependencies 27 + install-web-devs: 28 + cd web && $(NVM) && bun install 29 + 30 + ## Production build (WASM + tsc + Vite) 31 + web-build: wasm 32 + cd web && $(NVM) && bun run build
+15
README.md
··· 10 10 11 11 [Issue Tracker](https://issues.opake.app) · [Architecture](docs/ARCHITECTURE.md) · [Lexicons](lexicons/README.md) 12 12 13 + ## Install 14 + 15 + Requires Rust 1.75+. 16 + 17 + ```sh 18 + cargo install --path crates/opake-cli 19 + ``` 20 + 21 + This puts `opake` in your `~/.cargo/bin/`. 22 + 13 23 ## How It Works 14 24 15 25 ``` ··· 117 127 opake download --keyring-member at://did:plc:abc/app.opake.cloud.document/tid456 118 128 opake keyring remove-member family-photos alice.example.com 119 129 130 + # transfer encryption identity to a new device 131 + opake pair request # on the NEW device (polls for approval) 132 + opake pair approve # on the EXISTING device (select + approve) 133 + 120 134 # remove an account (defaults to only account if just one) 121 135 opake logout 122 136 opake logout bob.other.com ··· 163 177 - [x] Keyring-based group sharing 164 178 - [ ] Web UI — cabinet file browser (in progress, auth stubbed) 165 179 - [x] AT Protocol OAuth (DPoP) for CLI and browser authentication 180 + - [x] Device-to-device identity pairing via PDS relay 166 181 - [ ] Seed phrase key derivation for multi-device 167 182 168 183 ## Development
+19
crates/opake-cli/src/commands/login.rs
··· 118 118 119 119 storage.save_config_anyhow(&cfg)?; 120 120 121 + let has_local_identity = identity::load_identity(storage, session.did()).is_ok(); 122 + let has_published_key = client 123 + .get_record( 124 + session.did(), 125 + opake_core::records::PUBLIC_KEY_COLLECTION, 126 + opake_core::records::PUBLIC_KEY_RKEY, 127 + ) 128 + .await 129 + .is_ok(); 130 + 131 + if !has_local_identity && has_published_key { 132 + // Existing identity on another device — don't generate a new one. 133 + println!("Logged in as {}", session.handle()); 134 + println!(); 135 + println!("This account has an existing encryption identity."); 136 + println!("Run `opake pair request` to transfer it from another device."); 137 + return Ok(Some(session)); 138 + } 139 + 121 140 let (identity, generated) = 122 141 identity::ensure_identity(storage, session.did(), &mut opake_core::crypto::OsRng)?; 123 142
+1
crates/opake-cli/src/commands/mod.rs
··· 8 8 pub mod ls; 9 9 pub mod mkdir; 10 10 pub mod mv; 11 + pub mod pair; 11 12 pub mod resolve; 12 13 pub mod revoke; 13 14 pub mod rm;
+213
crates/opake-cli/src/commands/pair.rs
··· 1 + use anyhow::{Context, Result}; 2 + use chrono::Utc; 3 + use clap::{Args, Subcommand}; 4 + use log::debug; 5 + use opake_core::atproto; 6 + use opake_core::client::Session; 7 + use opake_core::crypto::OsRng; 8 + use opake_core::pairing; 9 + use opake_core::records::{ 10 + PairRequest, PairResponse, PAIR_REQUEST_COLLECTION, PAIR_RESPONSE_COLLECTION, 11 + }; 12 + 13 + use crate::commands::Execute; 14 + use crate::identity; 15 + use crate::session::{self, CommandContext}; 16 + 17 + #[derive(Args)] 18 + /// Transfer encryption identity between devices 19 + pub struct PairCommand { 20 + #[command(subcommand)] 21 + action: PairAction, 22 + } 23 + 24 + #[derive(Subcommand)] 25 + enum PairAction { 26 + /// Request identity transfer from an existing device (run on the NEW device) 27 + Request(RequestArgs), 28 + /// Approve a pending pairing request (run on the EXISTING device) 29 + Approve(ApproveArgs), 30 + } 31 + 32 + #[derive(Args)] 33 + struct RequestArgs { 34 + /// Polling interval in seconds 35 + #[arg(long, default_value = "3")] 36 + interval: u64, 37 + } 38 + 39 + #[derive(Args)] 40 + struct ApproveArgs; 41 + 42 + impl Execute for PairCommand { 43 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 44 + match self.action { 45 + PairAction::Request(args) => request(ctx, args).await, 46 + PairAction::Approve(_args) => approve(ctx).await, 47 + } 48 + } 49 + } 50 + 51 + /// Format an ephemeral key fingerprint for visual SAS comparison. 52 + fn fingerprint(key: &[u8; 32]) -> String { 53 + key.iter() 54 + .take(8) 55 + .map(|b| format!("{b:02x}")) 56 + .collect::<Vec<_>>() 57 + .join(":") 58 + } 59 + 60 + async fn request(ctx: &CommandContext, args: RequestArgs) -> Result<Option<Session>> { 61 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 62 + 63 + // Bail if this device already has an identity — use `opake login` instead. 64 + if identity::load_identity(&ctx.storage, &ctx.did).is_ok() { 65 + anyhow::bail!( 66 + "this device already has an encryption identity for {}. \ 67 + If you need to replace it, delete the identity file first.", 68 + ctx.did 69 + ); 70 + } 71 + 72 + let (record_ref, ephemeral_keypair) = 73 + pairing::create_pair_request(&mut client, &Utc::now().to_rfc3339(), &mut OsRng).await?; 74 + 75 + let request_uri = &record_ref.uri; 76 + let request_at_uri = atproto::parse_at_uri(request_uri)?; 77 + 78 + println!("Pairing request created."); 79 + println!( 80 + "Fingerprint: {}", 81 + fingerprint(&ephemeral_keypair.public_key) 82 + ); 83 + println!(); 84 + println!("Run `opake pair approve` on your existing device."); 85 + println!("Waiting for response..."); 86 + 87 + // Poll for a matching pairResponse record. 88 + let interval = std::time::Duration::from_secs(args.interval); 89 + let response: PairResponse = loop { 90 + tokio::time::sleep(interval).await; 91 + debug!("polling for pair response..."); 92 + 93 + let page = client 94 + .list_records(PAIR_RESPONSE_COLLECTION, Some(100), None) 95 + .await?; 96 + 97 + let found = page.records.into_iter().find(|entry| { 98 + serde_json::from_value::<PairResponse>(entry.value.clone()) 99 + .map(|r| r.request == *request_uri) 100 + .unwrap_or(false) 101 + }); 102 + 103 + if let Some(entry) = found { 104 + break serde_json::from_value(entry.value)?; 105 + } 106 + }; 107 + 108 + let identity = pairing::receive_pair_response( 109 + &mut client, 110 + &ctx.did, 111 + &response, 112 + &ephemeral_keypair.private_key, 113 + ) 114 + .await?; 115 + 116 + identity::save_identity(&ctx.storage, &ctx.did, &identity)?; 117 + println!("Identity received and saved."); 118 + 119 + // Clean up both records. 120 + let response_page = client 121 + .list_records(PAIR_RESPONSE_COLLECTION, Some(100), None) 122 + .await?; 123 + let response_rkey = response_page 124 + .records 125 + .iter() 126 + .find(|entry| { 127 + serde_json::from_value::<PairResponse>(entry.value.clone()) 128 + .map(|r| r.request == *request_uri) 129 + .unwrap_or(false) 130 + }) 131 + .map(|entry| atproto::parse_at_uri(&entry.uri)) 132 + .transpose()? 133 + .map(|uri| uri.rkey); 134 + 135 + if let Some(ref rkey) = response_rkey { 136 + pairing::cleanup_pair_records(&mut client, &request_at_uri.rkey, rkey).await?; 137 + debug!("cleaned up pairing records"); 138 + } 139 + 140 + println!("Pairing complete."); 141 + Ok(session::refreshed_session(&client)) 142 + } 143 + 144 + async fn approve(ctx: &CommandContext) -> Result<Option<Session>> { 145 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 146 + let id = identity::load_identity(&ctx.storage, &ctx.did) 147 + .context("no local identity — run `opake pair approve` on the device that has one")?; 148 + 149 + let page = client 150 + .list_records(PAIR_REQUEST_COLLECTION, Some(100), None) 151 + .await?; 152 + 153 + if page.records.is_empty() { 154 + println!("No pending pairing requests."); 155 + return Ok(session::refreshed_session(&client)); 156 + } 157 + 158 + println!("Pending pairing requests:\n"); 159 + 160 + let mut requests: Vec<(String, PairRequest)> = Vec::new(); 161 + for (i, entry) in page.records.iter().enumerate() { 162 + let request: PairRequest = serde_json::from_value(entry.value.clone()) 163 + .context("failed to parse pair request record")?; 164 + 165 + let ephemeral_key_bytes = base64::engine::general_purpose::STANDARD 166 + .decode(&request.ephemeral_key.encoded) 167 + .context("invalid base64 in pair request ephemeral key")?; 168 + 169 + let fp = if ephemeral_key_bytes.len() == 32 { 170 + let arr: [u8; 32] = ephemeral_key_bytes.try_into().unwrap(); 171 + fingerprint(&arr) 172 + } else { 173 + "(invalid key length)".to_string() 174 + }; 175 + 176 + println!(" [{}] {} — fingerprint: {}", i + 1, request.created_at, fp); 177 + requests.push((entry.uri.clone(), request)); 178 + } 179 + 180 + println!(); 181 + eprint!("Approve which request? [1-{}] ", requests.len()); 182 + let mut input = String::new(); 183 + std::io::stdin().read_line(&mut input)?; 184 + let choice: usize = input.trim().parse().context("invalid selection")?; 185 + anyhow::ensure!( 186 + choice >= 1 && choice <= requests.len(), 187 + "selection out of range" 188 + ); 189 + 190 + let (ref request_uri, ref request) = requests[choice - 1]; 191 + 192 + let ephemeral_key_bytes = base64::engine::general_purpose::STANDARD 193 + .decode(&request.ephemeral_key.encoded) 194 + .context("invalid base64 in ephemeral key")?; 195 + let ephemeral_pubkey: [u8; 32] = ephemeral_key_bytes 196 + .try_into() 197 + .map_err(|_| anyhow::anyhow!("ephemeral key must be 32 bytes"))?; 198 + 199 + pairing::respond_to_pair_request( 200 + &mut client, 201 + &id, 202 + request_uri, 203 + &ephemeral_pubkey, 204 + &Utc::now().to_rfc3339(), 205 + &mut OsRng, 206 + ) 207 + .await?; 208 + 209 + println!("Identity sent. The other device should receive it shortly."); 210 + Ok(session::refreshed_session(&client)) 211 + } 212 + 213 + use base64::Engine;
+2
crates/opake-cli/src/main.rs
··· 50 50 Shared(commands::shared::SharedCommand), 51 51 Revoke(commands::revoke::RevokeCommand), 52 52 Keyring(commands::keyring::KeyringCommand), 53 + Pair(commands::pair::PairCommand), 53 54 Tree(commands::tree::TreeCommand), 54 55 } 55 56 ··· 116 117 Command::Shared(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 117 118 Command::Revoke(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 118 119 Command::Keyring(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 120 + Command::Pair(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 119 121 Command::Tree(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 120 122 } 121 123
+19
crates/opake-cli/src/oauth.rs
··· 212 212 session.clone(), 213 213 ); 214 214 215 + let has_local_identity = identity::load_identity(storage, &did).is_ok(); 216 + let has_published_key = client 217 + .get_record( 218 + &did, 219 + opake_core::records::PUBLIC_KEY_COLLECTION, 220 + opake_core::records::PUBLIC_KEY_RKEY, 221 + ) 222 + .await 223 + .is_ok(); 224 + 225 + if !has_local_identity && has_published_key { 226 + // Existing identity on another device — don't generate a new one. 227 + println!("Logged in as {handle} (OAuth)"); 228 + println!(); 229 + println!("This account has an existing encryption identity."); 230 + println!("Run `opake pair request` to transfer it from another device."); 231 + return Ok(session); 232 + } 233 + 215 234 let (identity, generated) = identity::ensure_identity(storage, &did, &mut OsRng)?; 216 235 if generated { 217 236 println!("Generated new encryption keypair");
+34
crates/opake-core/src/crypto/crypto_tests.rs
··· 165 165 assert_eq!(group_key.0, unwrapped_b.0); 166 166 } 167 167 168 + // -- Ephemeral keypair -- 169 + 170 + #[test] 171 + fn ephemeral_keypair_has_correct_key_lengths() { 172 + let kp = generate_ephemeral_keypair(&mut OsRng); 173 + assert_eq!(kp.public_key.len(), 32); 174 + assert_eq!(kp.private_key.len(), 32); 175 + } 176 + 177 + #[test] 178 + fn ephemeral_keypair_unique_each_time() { 179 + let a = generate_ephemeral_keypair(&mut OsRng); 180 + let b = generate_ephemeral_keypair(&mut OsRng); 181 + assert_ne!(a.public_key, b.public_key); 182 + assert_ne!(a.private_key, b.private_key); 183 + } 184 + 185 + #[test] 186 + fn ephemeral_keypair_compatible_with_wrap_unwrap() { 187 + let ephemeral = generate_ephemeral_keypair(&mut OsRng); 188 + let content_key = generate_content_key(&mut OsRng); 189 + 190 + let wrapped = wrap_key( 191 + &content_key, 192 + &ephemeral.public_key, 193 + "did:plc:ephemeral", 194 + &mut OsRng, 195 + ) 196 + .unwrap(); 197 + let unwrapped = unwrap_key(&wrapped, &ephemeral.private_key).unwrap(); 198 + 199 + assert_eq!(content_key.0, unwrapped.0); 200 + } 201 + 168 202 // -- Keyring wrapping (symmetric AES-KW) -- 169 203 170 204 #[test]
+17
crates/opake-core/src/crypto/mod.rs
··· 82 82 /// A DID string paired with its X25519 public key. 83 83 pub type DidPublicKey<'a> = (&'a str, &'a X25519PublicKey); 84 84 85 + /// An ephemeral X25519 keypair for one-time key exchanges (e.g. device pairing). 86 + /// The private key is held in memory only — never persisted. 87 + pub struct EphemeralKeypair { 88 + pub public_key: X25519PublicKey, 89 + pub private_key: X25519PrivateKey, 90 + } 91 + 92 + /// Generate a fresh ephemeral X25519 keypair for a one-time DH exchange. 93 + pub fn generate_ephemeral_keypair(rng: &mut (impl CryptoRng + RngCore)) -> EphemeralKeypair { 94 + let secret = X25519DalekStaticSecret::random_from_rng(&mut *rng); 95 + let public = X25519DalekPublicKey::from(&secret); 96 + EphemeralKeypair { 97 + public_key: *public.as_bytes(), 98 + private_key: secret.to_bytes(), 99 + } 100 + } 101 + 85 102 /// The result of encrypting plaintext content. 86 103 #[derive(crate::RedactedDebug)] 87 104 pub struct EncryptedPayload {
+1
crates/opake-core/src/lib.rs
··· 25 25 pub mod documents; 26 26 pub mod error; 27 27 pub mod keyrings; 28 + pub mod pairing; 28 29 pub mod paths; 29 30 pub mod records; 30 31 pub mod resolve;
+18
crates/opake-core/src/pairing/cleanup.rs
··· 1 + use crate::client::{Transport, XrpcClient}; 2 + use crate::error::Error; 3 + use crate::records::{PAIR_REQUEST_COLLECTION, PAIR_RESPONSE_COLLECTION}; 4 + 5 + /// Delete pair request and response records from the PDS after a successful transfer. 6 + pub async fn cleanup_pair_records( 7 + client: &mut XrpcClient<impl Transport>, 8 + request_rkey: &str, 9 + response_rkey: &str, 10 + ) -> Result<(), Error> { 11 + client 12 + .delete_record(PAIR_REQUEST_COLLECTION, request_rkey) 13 + .await?; 14 + client 15 + .delete_record(PAIR_RESPONSE_COLLECTION, response_rkey) 16 + .await?; 17 + Ok(()) 18 + }
+20
crates/opake-core/src/pairing/mod.rs
··· 1 + // Device-to-device identity pairing via the PDS. 2 + // 3 + // When a user logs in on a new device, they need their X25519 identity 4 + // keypair transferred from an existing device. Both devices are authenticated 5 + // to the same DID, so they can read/write records in the same PDS repo. 6 + // 7 + // The protocol uses an ephemeral DH key exchange: the new device publishes 8 + // an ephemeral public key, the existing device wraps the identity to that 9 + // key, and writes the encrypted payload as a record. All records are deleted 10 + // after the transfer completes. 11 + 12 + mod cleanup; 13 + mod receive; 14 + mod request; 15 + mod respond; 16 + 17 + pub use cleanup::cleanup_pair_records; 18 + pub use receive::receive_pair_response; 19 + pub use request::create_pair_request; 20 + pub use respond::respond_to_pair_request;
+66
crates/opake-core/src/pairing/receive.rs
··· 1 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 2 + 3 + use crate::client::{Transport, XrpcClient}; 4 + use crate::crypto::{decrypt_blob, unwrap_key, EncryptedPayload, X25519PrivateKey}; 5 + use crate::error::Error; 6 + use crate::records::{PairResponse, PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; 7 + use crate::storage::Identity; 8 + 9 + /// Receive and decrypt an identity from a pairing response. 10 + /// 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( 14 + client: &mut XrpcClient<impl Transport>, 15 + did: &str, 16 + response: &PairResponse, 17 + ephemeral_private_key: &X25519PrivateKey, 18 + ) -> Result<Identity, Error> { 19 + let content_key = unwrap_key(&response.wrapped_key, ephemeral_private_key)?; 20 + 21 + let ciphertext = BASE64.decode(&response.ciphertext.encoded).map_err(|e| { 22 + Error::Decryption(format!("invalid base64 in pair response ciphertext: {e}")) 23 + })?; 24 + let nonce_bytes = BASE64 25 + .decode(&response.nonce.encoded) 26 + .map_err(|e| Error::Decryption(format!("invalid base64 in pair response nonce: {e}")))?; 27 + let nonce_len = nonce_bytes.len(); 28 + let nonce: [u8; 12] = nonce_bytes.try_into().map_err(|_| { 29 + Error::Decryption(format!( 30 + "pair response nonce must be 12 bytes, got {nonce_len}" 31 + )) 32 + })?; 33 + 34 + let payload = EncryptedPayload { ciphertext, nonce }; 35 + let plaintext = decrypt_blob(&content_key, &payload)?; 36 + 37 + let identity: Identity = serde_json::from_slice(&plaintext).map_err(|e| { 38 + Error::InvalidRecord(format!( 39 + "pair response contained invalid identity JSON: {e}" 40 + )) 41 + })?; 42 + 43 + // Verify: the decrypted public key must match the published publicKey/self record. 44 + let record_entry = client 45 + .get_record(did, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY) 46 + .await?; 47 + let published: PublicKeyRecord = serde_json::from_value(record_entry.value)?; 48 + let published_key = BASE64.decode(&published.public_key.encoded).map_err(|e| { 49 + Error::InvalidRecord(format!("invalid base64 in published public key: {e}")) 50 + })?; 51 + 52 + let received_key = BASE64.decode(&identity.public_key).map_err(|e| { 53 + Error::InvalidRecord(format!( 54 + "invalid base64 in received identity public key: {e}" 55 + )) 56 + })?; 57 + 58 + if published_key != received_key { 59 + return Err(Error::InvalidRecord( 60 + "received identity public key does not match published publicKey/self record" 61 + .to_string(), 62 + )); 63 + } 64 + 65 + Ok(identity) 66 + }
+20
crates/opake-core/src/pairing/request.rs
··· 1 + use crate::client::{RecordRef, Transport, XrpcClient}; 2 + use crate::crypto::{generate_ephemeral_keypair, CryptoRng, EphemeralKeypair, RngCore}; 3 + use crate::error::Error; 4 + use crate::records::{PairRequest, PAIR_REQUEST_COLLECTION}; 5 + 6 + /// Create a pairing request on the PDS and return the record URI + ephemeral keypair. 7 + /// 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>, 11 + created_at: &str, 12 + rng: &mut (impl CryptoRng + RngCore), 13 + ) -> Result<(RecordRef, EphemeralKeypair), Error> { 14 + let keypair = generate_ephemeral_keypair(rng); 15 + let record = PairRequest::new(&keypair.public_key, created_at); 16 + let record_ref = client 17 + .create_record(PAIR_REQUEST_COLLECTION, &record) 18 + .await?; 19 + Ok((record_ref, keypair)) 20 + }
+49
crates/opake-core/src/pairing/respond.rs
··· 1 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 2 + 3 + use crate::atproto::AtBytes; 4 + use crate::client::{Transport, XrpcClient}; 5 + use crate::crypto::{ 6 + encrypt_blob, generate_content_key, wrap_key, CryptoRng, RngCore, X25519PublicKey, 7 + }; 8 + use crate::error::Error; 9 + use crate::records::{PairResponse, PAIR_RESPONSE_COLLECTION, SCHEMA_VERSION}; 10 + use crate::storage::Identity; 11 + 12 + /// Respond to a pairing request by encrypting the local identity to the 13 + /// requester's ephemeral public key and writing a pairResponse record. 14 + pub async fn respond_to_pair_request( 15 + client: &mut XrpcClient<impl Transport>, 16 + identity: &Identity, 17 + request_uri: &str, 18 + ephemeral_public_key: &X25519PublicKey, 19 + created_at: &str, 20 + rng: &mut (impl CryptoRng + RngCore), 21 + ) -> Result<(), Error> { 22 + let content_key = generate_content_key(rng); 23 + 24 + let identity_json = serde_json::to_vec(identity)?; 25 + let payload = encrypt_blob(&content_key, &identity_json, rng)?; 26 + 27 + // Wrap the content key to the ephemeral public key. The DID field in 28 + // the WrappedKey is the identity's DID — it identifies who is sending. 29 + let wrapped = wrap_key(&content_key, ephemeral_public_key, &identity.did, rng)?; 30 + 31 + let record = PairResponse { 32 + version: SCHEMA_VERSION, 33 + request: request_uri.to_string(), 34 + wrapped_key: wrapped, 35 + ciphertext: AtBytes { 36 + encoded: BASE64.encode(&payload.ciphertext), 37 + }, 38 + nonce: AtBytes { 39 + encoded: BASE64.encode(payload.nonce), 40 + }, 41 + algo: "aes-256-gcm".into(), 42 + created_at: created_at.into(), 43 + }; 44 + 45 + client 46 + .create_record(PAIR_RESPONSE_COLLECTION, &record) 47 + .await?; 48 + Ok(()) 49 + }
+13 -1
crates/opake-core/src/records/mod.rs
··· 11 11 mod document; 12 12 mod grant; 13 13 mod keyring; 14 + mod pair_request; 15 + mod pair_response; 14 16 mod public_key; 15 17 16 18 use crate::error::Error; ··· 25 27 pub use document::{DirectEncryption, Document, Encryption, KeyringEncryption}; 26 28 pub use grant::Grant; 27 29 pub use keyring::{KeyHistoryEntry, Keyring}; 30 + pub use pair_request::{PairRequest, PAIR_REQUEST_COLLECTION}; 31 + pub use pair_response::{PairResponse, PAIR_RESPONSE_COLLECTION}; 28 32 pub use public_key::{PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; 29 33 30 34 /// The current app.opake.cloud.* schema version this client understands. ··· 44 48 }; 45 49 } 46 50 47 - impl_versioned!(Directory, Document, PublicKeyRecord, Grant, Keyring); 51 + impl_versioned!( 52 + Directory, 53 + Document, 54 + PublicKeyRecord, 55 + Grant, 56 + Keyring, 57 + PairRequest, 58 + PairResponse 59 + ); 48 60 49 61 fn default_version() -> u32 { 50 62 SCHEMA_VERSION
+36
crates/opake-core/src/records/pair_request.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use super::{default_version, SCHEMA_VERSION}; 4 + use crate::atproto::AtBytes; 5 + 6 + pub const PAIR_REQUEST_COLLECTION: &str = "app.opake.cloud.pairRequest"; 7 + 8 + /// A device pairing request. The new device publishes its ephemeral public key 9 + /// so the existing device can wrap the identity for secure transfer. 10 + #[derive(Debug, Clone, Serialize, Deserialize)] 11 + #[serde(rename_all = "camelCase")] 12 + pub struct PairRequest { 13 + #[serde(default = "default_version")] 14 + pub version: u32, 15 + pub ephemeral_key: AtBytes, 16 + pub algo: String, 17 + pub created_at: String, 18 + } 19 + 20 + impl PairRequest { 21 + pub fn new(ephemeral_key_bytes: &[u8], created_at: &str) -> Self { 22 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 23 + Self { 24 + version: SCHEMA_VERSION, 25 + ephemeral_key: AtBytes { 26 + encoded: BASE64.encode(ephemeral_key_bytes), 27 + }, 28 + algo: "x25519".into(), 29 + created_at: created_at.into(), 30 + } 31 + } 32 + } 33 + 34 + #[cfg(test)] 35 + #[path = "pair_request_tests.rs"] 36 + mod tests;
+28
crates/opake-core/src/records/pair_request_tests.rs
··· 1 + use super::*; 2 + 3 + #[test] 4 + fn pair_request_new_sets_defaults() { 5 + let record = PairRequest::new(&[42u8; 32], "2026-03-06T00:00:00Z"); 6 + assert_eq!(record.version, SCHEMA_VERSION); 7 + assert_eq!(record.algo, "x25519"); 8 + assert_eq!(record.created_at, "2026-03-06T00:00:00Z"); 9 + } 10 + 11 + #[test] 12 + fn pair_request_roundtrips_through_json() { 13 + let record = PairRequest::new(&[7u8; 32], "2026-03-06T12:00:00Z"); 14 + let json = serde_json::to_string(&record).unwrap(); 15 + let parsed: PairRequest = serde_json::from_str(&json).unwrap(); 16 + 17 + assert_eq!(parsed.version, record.version); 18 + assert_eq!(parsed.ephemeral_key.encoded, record.ephemeral_key.encoded); 19 + assert_eq!(parsed.algo, "x25519"); 20 + assert_eq!(parsed.created_at, "2026-03-06T12:00:00Z"); 21 + } 22 + 23 + #[test] 24 + fn pair_request_uses_atbytes_wire_format() { 25 + let record = PairRequest::new(&[1u8; 32], "2026-03-06T00:00:00Z"); 26 + let json = serde_json::to_value(&record).unwrap(); 27 + assert!(json["ephemeralKey"]["$bytes"].is_string()); 28 + }
+25
crates/opake-core/src/records/pair_response.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use super::{default_version, WrappedKey}; 4 + use crate::atproto::AtBytes; 5 + 6 + pub const PAIR_RESPONSE_COLLECTION: &str = "app.opake.cloud.pairResponse"; 7 + 8 + /// A device pairing response. The existing device encrypts its identity 9 + /// to the requesting device's ephemeral key and writes this record. 10 + #[derive(Debug, Clone, Serialize, Deserialize)] 11 + #[serde(rename_all = "camelCase")] 12 + pub struct PairResponse { 13 + #[serde(default = "default_version")] 14 + pub version: u32, 15 + pub request: String, 16 + pub wrapped_key: WrappedKey, 17 + pub ciphertext: AtBytes, 18 + pub nonce: AtBytes, 19 + pub algo: String, 20 + pub created_at: String, 21 + } 22 + 23 + #[cfg(test)] 24 + #[path = "pair_response_tests.rs"] 25 + mod tests;
+64
crates/opake-core/src/records/pair_response_tests.rs
··· 1 + use super::*; 2 + use crate::atproto::AtBytes; 3 + use crate::records::SCHEMA_VERSION; 4 + 5 + #[test] 6 + fn pair_response_roundtrips_through_json() { 7 + let record = PairResponse { 8 + version: SCHEMA_VERSION, 9 + request: "at://did:plc:test/app.opake.cloud.pairRequest/abc123".into(), 10 + wrapped_key: WrappedKey { 11 + did: "did:plc:test".into(), 12 + ciphertext: AtBytes { 13 + encoded: "AAAA".into(), 14 + }, 15 + algo: "x25519-hkdf-a256kw".into(), 16 + }, 17 + ciphertext: AtBytes { 18 + encoded: "BBBB".into(), 19 + }, 20 + nonce: AtBytes { 21 + encoded: "CCCC".into(), 22 + }, 23 + algo: "aes-256-gcm".into(), 24 + created_at: "2026-03-06T12:00:00Z".into(), 25 + }; 26 + 27 + let json = serde_json::to_string(&record).unwrap(); 28 + let parsed: PairResponse = serde_json::from_str(&json).unwrap(); 29 + 30 + assert_eq!(parsed.version, SCHEMA_VERSION); 31 + assert_eq!(parsed.request, record.request); 32 + assert_eq!(parsed.wrapped_key.did, "did:plc:test"); 33 + assert_eq!(parsed.wrapped_key.algo, "x25519-hkdf-a256kw"); 34 + assert_eq!(parsed.algo, "aes-256-gcm"); 35 + assert_eq!(parsed.created_at, "2026-03-06T12:00:00Z"); 36 + } 37 + 38 + #[test] 39 + fn pair_response_uses_atbytes_wire_format() { 40 + let record = PairResponse { 41 + version: SCHEMA_VERSION, 42 + request: "at://did:plc:test/app.opake.cloud.pairRequest/abc123".into(), 43 + wrapped_key: WrappedKey { 44 + did: "did:plc:test".into(), 45 + ciphertext: AtBytes { 46 + encoded: "AAAA".into(), 47 + }, 48 + algo: "x25519-hkdf-a256kw".into(), 49 + }, 50 + ciphertext: AtBytes { 51 + encoded: "BBBB".into(), 52 + }, 53 + nonce: AtBytes { 54 + encoded: "CCCC".into(), 55 + }, 56 + algo: "aes-256-gcm".into(), 57 + created_at: "2026-03-06T00:00:00Z".into(), 58 + }; 59 + 60 + let json = serde_json::to_value(&record).unwrap(); 61 + assert!(json["ciphertext"]["$bytes"].is_string()); 62 + assert!(json["nonce"]["$bytes"].is_string()); 63 + assert!(json["wrappedKey"]["ciphertext"]["$bytes"].is_string()); 64 + }
crates/opake-wasm/.crosslink/issues.db

This is a binary file and will not be displayed.

+21
crates/opake-wasm/src/lib.rs
··· 169 169 let identity = Identity::generate(did, &mut OsRng); 170 170 serde_wasm_bindgen::to_value(&identity).map_err(|e| JsError::new(&e.to_string())) 171 171 } 172 + 173 + // --------------------------------------------------------------------------- 174 + // Ephemeral keypair (for device pairing) 175 + // --------------------------------------------------------------------------- 176 + 177 + #[derive(Serialize)] 178 + #[serde(rename_all = "camelCase")] 179 + struct EphemeralKeypairDto { 180 + public_key: Vec<u8>, 181 + private_key: Vec<u8>, 182 + } 183 + 184 + #[wasm_bindgen(js_name = generateEphemeralKeypair)] 185 + pub fn generate_ephemeral_keypair() -> Result<JsValue, JsError> { 186 + let kp = opake_core::crypto::generate_ephemeral_keypair(&mut OsRng); 187 + let dto = EphemeralKeypairDto { 188 + public_key: kp.public_key.to_vec(), 189 + private_key: kp.private_key.to_vec(), 190 + }; 191 + serde_wasm_bindgen::to_value(&dto).map_err(|e| JsError::new(&e.to_string())) 192 + }
+39
docs/ARCHITECTURE.md
··· 111 111 create.rs create_grant() 112 112 list.rs list_grants() 113 113 revoke.rs revoke_grant() 114 + pairing/ 115 + mod.rs Re-exports 116 + request.rs create_pair_request() — write ephemeral key to PDS 117 + respond.rs respond_to_pair_request() — encrypt + wrap identity 118 + receive.rs receive_pair_response() — decrypt + verify identity 119 + cleanup.rs cleanup_pair_records() — delete request + response 114 120 115 121 opake-cli/ CLI binary wrapping opake-core 116 122 src/ ··· 134 140 revoke.rs Grant deletion 135 141 shared.rs List created grants 136 142 keyring.rs Keyring CRUD (create, ls, add-member, remove-member) 143 + pair.rs Device pairing (request, approve) 137 144 accounts.rs List accounts 138 145 logout.rs Remove account 139 146 set_default.rs Switch default account ··· 359 366 Group keys are stored locally because they never appear in plaintext on the PDS — only wrapped copies exist in the keyring record. Each keyring file holds an array of `{ rotation, group_key }` entries so that keys from previous rotations remain available for decrypting older documents. Legacy files (single `group_key` without rotation) are auto-migrated to rotation 0 on read. 360 367 361 368 The `--as <handle-or-did>` flag overrides the default account for any command. Future improvement: seed phrase derivation for the keypair instead of storing it in plaintext. 369 + 370 + ## Device Pairing 371 + 372 + When a user logs in on a new device, they need their X25519 identity keypair from the existing device. The PDS acts as a relay — both devices are authenticated to the same DID and can read/write records in the same repo. 373 + 374 + The protocol uses ephemeral X25519 Diffie-Hellman to establish a shared secret. The identity payload is encrypted with AES-256-GCM and the content key is wrapped to the ephemeral public key using the same `x25519-hkdf-a256kw` scheme as document encryption. Both `pairRequest` and `pairResponse` records are deleted after a successful transfer. 375 + 376 + ``` 377 + Device B (new) PDS Device A (existing) 378 + | | | 379 + |-- createRecord pairReq --->| | 380 + | { ephemeralKey } | | 381 + | |<--- listRecords pairReq ----| 382 + | |--- return pairRequest ------>| 383 + | | | 384 + | | DH + encrypt identity 385 + | | | 386 + | |<--- createRecord pairResp --| 387 + |-- listRecords pairResp --->| { wrappedKey, ciphertext } | 388 + |<-- return pairResponse ----| | 389 + | | | 390 + | unwrap + decrypt identity | | 391 + | verify pubkey matches | | 392 + | save identity.json | | 393 + | | | 394 + |-- deleteRecord pairReq --->| | 395 + |-- deleteRecord pairResp -->| | 396 + ``` 397 + 398 + Login on a second device detects an existing `publicKey/self` record and skips identity generation, directing the user to `opake pair request` instead. This prevents accidental key overwrites. 399 + 400 + See [docs/flows/pairing.md](flows/pairing.md) for the full sequence diagrams. 362 401 363 402 ## File Permissions 364 403
+1
docs/FLOWS.md
··· 11 11 | [flows/crypto.md](flows/crypto.md) | Key wrapping, content encryption primitives | 12 12 | [flows/keyrings.md](flows/keyrings.md) | Create, list, add/remove member, keyring upload/download | 13 13 | [flows/revisions.md](flows/revisions.md) | Collaborative editing via revision records (planned) | 14 + | [flows/pairing.md](flows/pairing.md) | Device-to-device identity transfer via PDS relay | 14 15 | [flows/multi-device.md](flows/multi-device.md) | Seed phrase identity, BIP-39 derivation (planned) |
+1
docs/flows/README.md
··· 11 11 | [crypto.md](crypto.md) | Key wrapping, content encryption primitives | 12 12 | [keyrings.md](keyrings.md) | Create, list, add/remove member, keyring upload/download | 13 13 | [revisions.md](revisions.md) | Collaborative editing via revision records (planned) | 14 + | [pairing.md](pairing.md) | Device-to-device identity transfer via PDS relay | 14 15 | [multi-device.md](multi-device.md) | Seed phrase identity, BIP-39 derivation (planned) |
+2
docs/flows/multi-device.md
··· 1 1 # Multi-Device Identity (Planned) 2 2 3 + > **Current MVP:** Device-to-device pairing transfers the existing identity via the PDS as a relay. See [pairing.md](pairing.md) for the implemented protocol. Seed phrase derivation (below) is a future replacement that eliminates the need for an existing device. 4 + 3 5 Deterministic keypair derivation from a BIP-39 mnemonic. Same seed on any device produces the same X25519 keypair — no key sync protocol needed. Replaces the current plaintext keypair file at `~/.config/opake/accounts/<did>/identity.json`. 4 6 5 7 ## Keypair Derivation
+137
docs/flows/pairing.md
··· 1 + # Device Pairing 2 + 3 + Transfer an encryption identity from an existing device to a new one, using the PDS as a relay. Both devices are authenticated to the same DID. 4 + 5 + ## Pair Request (new device) 6 + 7 + ```mermaid 8 + sequenceDiagram 9 + participant User 10 + participant CLI as CLI (new device) 11 + participant Crypto 12 + participant PDS 13 + 14 + User->>CLI: opake pair request 15 + 16 + CLI->>CLI: Verify no local identity exists 17 + 18 + CLI->>Crypto: generate_ephemeral_keypair() 19 + Crypto-->>CLI: { public_key, private_key } 20 + 21 + CLI->>PDS: createRecord(pairRequest)<br/>{ ephemeralKey, algo: "x25519" } 22 + PDS-->>CLI: { uri, cid } 23 + 24 + CLI->>User: Fingerprint: a1:b2:c3:d4:e5:f6:g7:h8 25 + CLI->>User: Run `opake pair approve` on existing device 26 + 27 + loop Poll every 3s 28 + CLI->>PDS: listRecords(pairResponse) 29 + PDS-->>CLI: records[] 30 + CLI->>CLI: Filter by response.request == our request URI 31 + end 32 + 33 + Note over CLI: Response found — see "Receive" below 34 + ``` 35 + 36 + The ephemeral private key stays in memory. The fingerprint (first 8 bytes of the public key, hex-encoded) is displayed for out-of-band verification. 37 + 38 + ## Pair Approve (existing device) 39 + 40 + ```mermaid 41 + sequenceDiagram 42 + participant User 43 + participant CLI as CLI (existing device) 44 + participant Crypto 45 + participant PDS 46 + 47 + User->>CLI: opake pair approve 48 + 49 + CLI->>CLI: Load local identity 50 + 51 + CLI->>PDS: listRecords(pairRequest) 52 + PDS-->>CLI: Pending requests 53 + 54 + CLI->>User: [1] 2026-03-06T14:00:00Z — fingerprint: a1:b2:c3:d4:... 55 + User-->>CLI: 1 56 + 57 + CLI->>Crypto: generate_content_key() 58 + Crypto-->>CLI: K (256-bit AES key) 59 + 60 + CLI->>CLI: Serialize identity → JSON bytes 61 + 62 + CLI->>Crypto: encrypt_blob(K, identity_json) 63 + Crypto-->>CLI: { ciphertext, nonce } 64 + 65 + CLI->>Crypto: wrap_key(K, ephemeral_pubkey, did) 66 + Crypto-->>CLI: wrappedKey 67 + 68 + CLI->>PDS: createRecord(pairResponse)<br/>{ request, wrappedKey, ciphertext, nonce } 69 + PDS-->>CLI: { uri, cid } 70 + 71 + CLI->>User: Identity sent. 72 + ``` 73 + 74 + The identity payload includes the X25519 encryption keypair, Ed25519 signing keypair, and the DID — everything needed to operate as that account. 75 + 76 + ## Receive (new device, after poll succeeds) 77 + 78 + ```mermaid 79 + sequenceDiagram 80 + participant CLI as CLI (new device) 81 + participant Crypto 82 + participant PDS 83 + 84 + Note over CLI: Poll found a matching pairResponse 85 + 86 + CLI->>Crypto: unwrap_key(wrappedKey, ephemeral_private_key) 87 + Crypto-->>CLI: K (content key) 88 + 89 + CLI->>Crypto: decrypt_blob(K, ciphertext, nonce) 90 + Crypto-->>CLI: identity JSON bytes 91 + 92 + CLI->>CLI: Deserialize → Identity 93 + 94 + CLI->>PDS: getRecord(publicKey/self) 95 + PDS-->>CLI: Published public key 96 + 97 + CLI->>CLI: Verify identity's public key == published key 98 + 99 + CLI->>CLI: Save identity.json (0600) 100 + 101 + CLI->>PDS: deleteRecord(pairRequest) 102 + CLI->>PDS: deleteRecord(pairResponse) 103 + 104 + CLI->>CLI: Pairing complete 105 + ``` 106 + 107 + The verification step guards against a corrupted or tampered response — the derived public key must match what's already published on the PDS. 108 + 109 + ## Login Detection 110 + 111 + When `opake login` runs on a device without a local identity, it checks for an existing `publicKey/self` record on the PDS before generating a new keypair: 112 + 113 + ```mermaid 114 + sequenceDiagram 115 + participant CLI 116 + participant PDS 117 + 118 + CLI->>PDS: getRecord(publicKey/self) 119 + 120 + alt No published key (new user) 121 + CLI->>CLI: Generate identity, publish key 122 + else Published key exists, no local identity 123 + CLI->>CLI: Save session only 124 + CLI->>CLI: Print: "Run opake pair request" 125 + else Published key exists, local identity matches 126 + CLI->>CLI: Proceed normally 127 + end 128 + ``` 129 + 130 + This prevents accidental key overwrites that would break encryption on the existing device. 131 + 132 + ## Security Properties 133 + 134 + - **Ephemeral key exchange** — the DH keypair exists only in memory during the pairing session. No long-term secret is exposed in the PDS records. 135 + - **Visual SAS** — key fingerprints are displayed for comparison but not programmatically enforced. True zero-trust verification is a follow-up. 136 + - **Same encryption as documents** — the identity payload uses AES-256-GCM + x25519-hkdf-a256kw, the same primitives as file encryption. No new crypto. 137 + - **Record cleanup** — both pairing records are deleted after transfer. Stale request cleanup is tracked separately.
+46
lexicons/EXAMPLES.md
··· 209 209 - For true revocation of old content: re-encrypt affected documents with new content keys (see #88) 210 210 211 211 212 + ## 6. Pair request (new device requesting identity) 213 + 214 + A new device generates an ephemeral X25519 keypair and publishes the public half. The fingerprint is displayed for visual comparison on both devices. 215 + 216 + ```json 217 + { 218 + "$type": "app.opake.cloud.pairRequest", 219 + "version": 1, 220 + "ephemeralKey": { "$bytes": "base64-encoded-32-byte-x25519-ephemeral-public-key" }, 221 + "algo": "x25519", 222 + "createdAt": "2026-03-06T14:00:00.000Z" 223 + } 224 + ``` 225 + 226 + This record uses a TID rkey (multiple pending requests are possible). The existing device lists these to show pending requests. Both devices display the key fingerprint for out-of-band verification. 227 + 228 + ## 7. Pair response (existing device sending identity) 229 + 230 + The existing device encrypts the full identity (X25519 + Ed25519 keypairs) and wraps the content key to the ephemeral public key from the request. 231 + 232 + ```json 233 + { 234 + "$type": "app.opake.cloud.pairResponse", 235 + "version": 1, 236 + "request": "at://did:plc:alice123/app.opake.cloud.pairRequest/3kabcd", 237 + "wrappedKey": { 238 + "did": "did:plc:alice123", 239 + "ciphertext": { "$bytes": "base64-content-key-wrapped-to-ephemeral-pubkey" }, 240 + "algo": "x25519-hkdf-a256kw" 241 + }, 242 + "ciphertext": { "$bytes": "base64-aes-256-gcm-encrypted-identity-json" }, 243 + "nonce": { "$bytes": "base64-encoded-12-byte-nonce" }, 244 + "algo": "aes-256-gcm", 245 + "createdAt": "2026-03-06T14:01:00.000Z" 246 + } 247 + ``` 248 + 249 + **How the new device decrypts:** 250 + 1. Unwraps the content key using the ephemeral private key (held in memory) 251 + 2. Decrypts the ciphertext with the content key + nonce → identity JSON 252 + 3. Verifies the derived public key matches the published `publicKey/self` record 253 + 4. Saves the identity to disk 254 + 255 + Both records are deleted after successful transfer. The ephemeral keypair is never persisted — it exists only in memory during the pairing session. 256 + 257 + 212 258 ## Design Decisions & Notes 213 259 214 260 ### Why plaintext metadata?
+44
lexicons/README.md
··· 20 20 | `app.opake.cloud.publicKey` | record | Singleton X25519 encryption public key (rkey: `self`) for key discovery | 21 21 | `app.opake.cloud.keyring` | record | A named group with a shared symmetric key, wrapped to each member | 22 22 | `app.opake.cloud.grant` | record | A share grant — gives a DID access to a specific document's key | 23 + | `app.opake.cloud.pairRequest` | record | Ephemeral public key from a new device requesting identity transfer | 24 + | `app.opake.cloud.pairResponse` | record | Encrypted identity payload sent in response to a pair request | 23 25 24 26 ## Flow: Sharing a file with another DID 25 27 ··· 84 86 ``` 85 87 86 88 Any keyring member unwraps GK with their private key, then uses GK to unwrap each document's content key K. Removing a member archives the old rotation's member entries into `keyHistory`, then rotates GK and re-wraps to the remaining members — per-document content keys and blobs stay untouched. The history lets remaining members decrypt pre-rotation documents even on new devices. 89 + 90 + ## Flow: Device-to-device identity pairing 91 + 92 + ```mermaid 93 + sequenceDiagram 94 + participant DevB as Device B (new) 95 + participant PDS 96 + participant DevA as Device A (existing) 97 + 98 + Note over DevB,PDS: 1. New device creates pair request 99 + DevB->>DevB: Generate ephemeral X25519 keypair 100 + DevB->>PDS: createRecord(pairRequest, { ephemeralKey }) 101 + DevB->>DevB: Display key fingerprint 102 + DevB->>DevB: Poll for pairResponse... 103 + 104 + Note over DevA,PDS: 2. Existing device approves 105 + DevA->>PDS: listRecords(pairRequest) 106 + PDS-->>DevA: Pending requests with fingerprints 107 + DevA->>DevA: User confirms matching fingerprint 108 + 109 + Note over DevA,PDS: 3. Existing device sends identity 110 + DevA->>DevA: Generate content key K 111 + DevA->>DevA: Serialize identity → JSON 112 + DevA->>DevA: Encrypt identity with K (AES-256-GCM) 113 + DevA->>DevA: Wrap K to ephemeral pubkey (x25519-hkdf-a256kw) 114 + DevA->>PDS: createRecord(pairResponse, { wrappedKey, ciphertext }) 115 + 116 + Note over DevB,PDS: 4. New device receives identity 117 + DevB->>PDS: listRecords(pairResponse) 118 + PDS-->>DevB: Matching response 119 + DevB->>DevB: Unwrap K with ephemeral private key 120 + DevB->>DevB: Decrypt identity JSON 121 + DevB->>PDS: getRecord(publicKey/self) 122 + DevB->>DevB: Verify public key matches published key 123 + DevB->>DevB: Save identity.json 124 + 125 + Note over DevB,PDS: 5. Cleanup 126 + DevB->>PDS: deleteRecord(pairRequest) 127 + DevB->>PDS: deleteRecord(pairResponse) 128 + ``` 129 + 130 + Both devices are authenticated to the same DID. The PDS is just a relay — the encryption is the access control. Ephemeral key fingerprints are displayed for visual SAS comparison. 87 131 88 132 For detailed sequence diagrams of every CLI operation, see [docs/flows/](../docs/flows/).
+37
lexicons/app.opake.cloud.pairRequest.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.opake.cloud.pairRequest", 4 + "description": "A device pairing request. Created by a new device that needs the existing encryption identity transferred from another device. The ephemeral key is used for a one-time DH key exchange to protect the identity in transit.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["version", "ephemeralKey", "algo", "createdAt"], 12 + "properties": { 13 + "version": { 14 + "type": "integer", 15 + "minimum": 1, 16 + "description": "Schema version for forward compatibility." 17 + }, 18 + "ephemeralKey": { 19 + "type": "bytes", 20 + "maxLength": 32, 21 + "description": "Ephemeral X25519 public key for the DH exchange. The new device holds the corresponding private key in memory." 22 + }, 23 + "algo": { 24 + "type": "string", 25 + "knownValues": ["x25519"], 26 + "description": "Key algorithm identifier." 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime", 31 + "description": "When this pairing request was created." 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+52
lexicons/app.opake.cloud.pairResponse.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.opake.cloud.pairResponse", 4 + "description": "A device pairing response. Created by the existing device after approving a pairing request. Contains the encryption identity encrypted to the requesting device's ephemeral key via DH + HKDF + AES-KW + AES-256-GCM.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["version", "request", "wrappedKey", "ciphertext", "nonce", "algo", "createdAt"], 12 + "properties": { 13 + "version": { 14 + "type": "integer", 15 + "minimum": 1, 16 + "description": "Schema version for forward compatibility." 17 + }, 18 + "request": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT URI of the pairRequest record this responds to." 22 + }, 23 + "wrappedKey": { 24 + "type": "ref", 25 + "ref": "app.opake.cloud.defs#wrappedKey", 26 + "description": "Content encryption key wrapped to the ephemeral key from the pair request." 27 + }, 28 + "ciphertext": { 29 + "type": "bytes", 30 + "maxLength": 4096, 31 + "description": "The serialized identity JSON, encrypted with AES-256-GCM using the content key." 32 + }, 33 + "nonce": { 34 + "type": "bytes", 35 + "maxLength": 24, 36 + "description": "Nonce/IV for the AES-256-GCM encryption." 37 + }, 38 + "algo": { 39 + "type": "string", 40 + "knownValues": ["aes-256-gcm"], 41 + "description": "Symmetric algorithm used for the identity payload encryption." 42 + }, 43 + "createdAt": { 44 + "type": "string", 45 + "format": "datetime", 46 + "description": "When this pairing response was created." 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+6
web/src/lib/crypto-types.ts
··· 33 33 verifier: string; 34 34 challenge: string; 35 35 } 36 + 37 + // Mirrors: opake-core EphemeralKeypair (crypto/mod.rs) 38 + export interface EphemeralKeypair { 39 + publicKey: Uint8Array; 40 + privateKey: Uint8Array; 41 + }
+6 -1
web/src/workers/crypto.worker.ts
··· 12 12 createDpopProof as wasmCreateDpopProof, 13 13 generatePkce as wasmGeneratePkce, 14 14 generateIdentity as wasmGenerateIdentity, 15 + generateEphemeralKeypair as wasmGenerateEphemeralKeypair, 15 16 } from "@/wasm/opake-wasm/opake"; 16 - import type { EncryptedPayload, WrappedKey, DpopKeyPair, PkceChallenge } from "@/lib/crypto-types"; 17 + import type { EncryptedPayload, WrappedKey, DpopKeyPair, PkceChallenge, EphemeralKeypair } from "@/lib/crypto-types"; 17 18 import type { Identity } from "@/lib/storage-types"; 18 19 19 20 await init(); ··· 99 100 100 101 generateIdentity(did: string): Identity { 101 102 return wasmGenerateIdentity(did) as Identity; 103 + }, 104 + 105 + generateEphemeralKeypair(): EphemeralKeypair { 106 + return wasmGenerateEphemeralKeypair() as EphemeralKeypair; 102 107 }, 103 108 }; 104 109