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 936457e1 376f8af3

Waiting for spindle ...
+820 -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)
+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
+10
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 ```
+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 + }
+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