An encrypted personal cloud built on the AT Protocol.

Add publicKey lexicon and PublicKeyRecord struct #1

merged opened by sans-self.org targeting main from phase2/multi-account-and-did-resolution

New singleton record type (app.opake.cloud.publicKey, rkey: "self") for publishing X25519 encryption public keys on a user's PDS. Uses AtBytes for wire-compatible $bytes serialization.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:wydyrngmxbcsqdvhmd7whmye/sh.tangled.repo.pull/3mfydsf5bwm22
+533 -129
Diff #0
+2 -2
crates/opake-cli/src/commands/download.rs
··· 43 43 44 44 impl Execute for DownloadCommand { 45 45 async fn execute(self) -> Result<Option<Session>> { 46 - let mut client = session::load_client()?; 47 - let id = identity::load_identity().context("run `opake login` first")?; 46 + let mut client = session::load_client_default()?; 47 + let id = identity::load_identity_default().context("run `opake login` first")?; 48 48 let private_key = id.private_key_bytes()?; 49 49 50 50 let uri = documents::resolve_uri(&mut client, &self.reference).await?;
+23 -6
crates/opake-cli/src/commands/login.rs
··· 4 4 use opake_core::client::{Session, XrpcClient}; 5 5 6 6 use crate::commands::Execute; 7 - use crate::config; 7 + use crate::config::{self, AccountConfig}; 8 8 use crate::identity; 9 9 use crate::transport::ReqwestTransport; 10 10 use crate::utils::prefixed_get_env; 11 + 12 + use std::collections::BTreeMap; 11 13 12 14 /// Resolve password from env var or a fallback function (e.g. stdin prompt). 13 15 pub fn resolve_password( ··· 55 57 56 58 let session = client.login(self.identifier.trim(), &password).await?; 57 59 58 - // PDS URL is login-specific config — write it here, session 59 - // persistence is handled by the dispatch layer. 60 - config::save_config(&config::Config { 61 - pds_url: self.pds.clone(), 62 - })?; 60 + // Register this account in the config. Merge into existing accounts 61 + // if present, set as default if it's the first one. 62 + let mut cfg = config::load_config().unwrap_or(config::Config { 63 + default_did: None, 64 + accounts: BTreeMap::new(), 65 + }); 66 + 67 + cfg.accounts.insert( 68 + session.did.clone(), 69 + AccountConfig { 70 + pds_url: self.pds.clone(), 71 + handle: session.handle.clone(), 72 + }, 73 + ); 74 + 75 + if cfg.default_did.is_none() { 76 + cfg.default_did = Some(session.did.clone()); 77 + } 78 + 79 + config::save_config(&cfg)?; 63 80 64 81 let (_, generated) = 65 82 identity::ensure_identity(&session.did, &mut opake_core::crypto::OsRng)?;
+1 -1
crates/opake-cli/src/commands/ls.rs
··· 73 73 74 74 impl Execute for LsCommand { 75 75 async fn execute(self) -> Result<Option<Session>> { 76 - let mut client = session::load_client()?; 76 + let mut client = session::load_client_default()?; 77 77 let mut entries = documents::list_documents(&mut client).await?; 78 78 79 79 if let Some(ref tag) = self.tag {
+1 -1
crates/opake-cli/src/commands/rm.rs
··· 19 19 20 20 impl Execute for RmCommand { 21 21 async fn execute(self) -> Result<Option<Session>> { 22 - let mut client = session::load_client()?; 22 + let mut client = session::load_client_default()?; 23 23 let uri = documents::resolve_uri(&mut client, &self.reference).await?; 24 24 25 25 if !self.yes {
+2 -2
crates/opake-cli/src/commands/upload.rs
··· 34 34 anyhow::bail!("--keyring not yet supported (tracking: chainlink #21)"); 35 35 } 36 36 37 - let mut client = session::load_client()?; 38 - let id = identity::load_identity()?; 37 + let mut client = session::load_client_default()?; 38 + let id = identity::load_identity_default()?; 39 39 let owner_pubkey = id.public_key_bytes()?; 40 40 41 41 let plaintext =
+157 -26
crates/opake-cli/src/config.rs
··· 1 + use std::collections::BTreeMap; 1 2 use std::fs; 2 3 use std::path::PathBuf; 3 4 ··· 5 6 use serde::de::DeserializeOwned; 6 7 use serde::{Deserialize, Serialize}; 7 8 8 - /// Persistent CLI configuration (PDS URL, preferences). 9 + /// Persistent CLI configuration — tracks all logged-in accounts. 9 10 #[derive(Debug, Serialize, Deserialize)] 10 11 pub struct Config { 12 + pub default_did: Option<String>, 13 + #[serde(default)] 14 + pub accounts: BTreeMap<String, AccountConfig>, 15 + } 16 + 17 + /// Per-account configuration stored in the global config.toml. 18 + #[derive(Debug, Serialize, Deserialize)] 19 + pub struct AccountConfig { 11 20 pub pds_url: String, 21 + pub handle: String, 12 22 } 13 23 14 24 /// Where Opake stores its state on disk. Overridable via `OPAKE_DATA_DIR` ··· 31 41 Ok(()) 32 42 } 33 43 34 - /// Serialize a value to a JSON file in the data directory. 35 - pub fn save_json<T: Serialize>(filename: &str, value: &T) -> anyhow::Result<()> { 36 - ensure_data_dir()?; 37 - let json = serde_json::to_string_pretty(value) 38 - .with_context(|| format!("failed to serialize {filename}"))?; 39 - fs::write(data_dir().join(filename), json) 40 - .with_context(|| format!("failed to write {filename}")) 41 - } 42 - 43 - /// Deserialize a value from a JSON file in the data directory. 44 - pub fn load_json<T: DeserializeOwned>(filename: &str) -> anyhow::Result<T> { 45 - let path = data_dir().join(filename); 46 - let content = fs::read_to_string(&path) 47 - .with_context(|| format!("no {filename} found: run `opake login` first"))?; 48 - serde_json::from_str(&content).with_context(|| format!("failed to parse {filename}")) 49 - } 50 - 51 44 pub fn save_config(config: &Config) -> anyhow::Result<()> { 52 45 ensure_data_dir()?; 53 46 let content = toml::to_string_pretty(config).context("failed to serialize config")?; ··· 61 54 toml::from_str(&content).context("failed to parse config.toml") 62 55 } 63 56 57 + /// Make a DID safe for use as a directory name: `did:plc:abc` → `did_plc_abc`. 58 + pub fn sanitize_did(did: &str) -> String { 59 + did.replace(':', "_") 60 + } 61 + 62 + /// Path to an account's private data directory. 63 + pub fn account_dir(did: &str) -> PathBuf { 64 + data_dir().join("accounts").join(sanitize_did(did)) 65 + } 66 + 67 + /// Create the account directory (and parents) if it doesn't exist. 68 + pub fn ensure_account_dir(did: &str) -> anyhow::Result<()> { 69 + let dir = account_dir(did); 70 + if !dir.exists() { 71 + fs::create_dir_all(&dir) 72 + .with_context(|| format!("failed to create account directory: {}", dir.display()))?; 73 + } 74 + Ok(()) 75 + } 76 + 77 + /// Serialize a value to a JSON file inside an account's directory. 78 + pub fn save_account_json<T: Serialize>(did: &str, filename: &str, value: &T) -> anyhow::Result<()> { 79 + ensure_account_dir(did)?; 80 + let json = serde_json::to_string_pretty(value) 81 + .with_context(|| format!("failed to serialize {filename}"))?; 82 + fs::write(account_dir(did).join(filename), json) 83 + .with_context(|| format!("failed to write {filename} for {did}")) 84 + } 85 + 86 + /// Deserialize a value from a JSON file inside an account's directory. 87 + pub fn load_account_json<T: DeserializeOwned>(did: &str, filename: &str) -> anyhow::Result<T> { 88 + let path = account_dir(did).join(filename); 89 + let content = fs::read_to_string(&path) 90 + .with_context(|| format!("no {filename} for {did}: run `opake login` first"))?; 91 + serde_json::from_str(&content).with_context(|| format!("failed to parse {filename} for {did}")) 92 + } 93 + 64 94 #[cfg(test)] 65 95 mod tests { 66 96 use super::*; 67 97 use crate::utils::test_harness::with_test_dir; 68 98 99 + fn test_config(did: &str, pds_url: &str, handle: &str) -> Config { 100 + let mut accounts = BTreeMap::new(); 101 + accounts.insert( 102 + did.to_string(), 103 + AccountConfig { 104 + pds_url: pds_url.into(), 105 + handle: handle.into(), 106 + }, 107 + ); 108 + Config { 109 + default_did: Some(did.to_string()), 110 + accounts, 111 + } 112 + } 113 + 69 114 #[test] 70 115 fn save_and_load_config_roundtrip() { 71 116 with_test_dir(|_| { 117 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 118 + save_config(&config).unwrap(); 119 + 120 + let loaded = load_config().unwrap(); 121 + assert_eq!(loaded.default_did.unwrap(), "did:plc:alice"); 122 + let acc = loaded.accounts.get("did:plc:alice").unwrap(); 123 + assert_eq!(acc.pds_url, "https://pds.test"); 124 + assert_eq!(acc.handle, "alice.test"); 125 + }); 126 + } 127 + 128 + #[test] 129 + fn config_with_multiple_accounts_roundtrips() { 130 + with_test_dir(|_| { 131 + let mut accounts = BTreeMap::new(); 132 + accounts.insert( 133 + "did:plc:alice".into(), 134 + AccountConfig { 135 + pds_url: "https://pds.alice".into(), 136 + handle: "alice.test".into(), 137 + }, 138 + ); 139 + accounts.insert( 140 + "did:plc:bob".into(), 141 + AccountConfig { 142 + pds_url: "https://pds.bob".into(), 143 + handle: "bob.test".into(), 144 + }, 145 + ); 72 146 let config = Config { 73 - pds_url: "https://pds.test".into(), 147 + default_did: Some("did:plc:alice".into()), 148 + accounts, 74 149 }; 75 150 save_config(&config).unwrap(); 76 151 77 152 let loaded = load_config().unwrap(); 78 - assert_eq!(loaded.pds_url, "https://pds.test"); 153 + assert_eq!(loaded.accounts.len(), 2); 154 + assert_eq!( 155 + loaded.accounts.get("did:plc:bob").unwrap().handle, 156 + "bob.test" 157 + ); 158 + }); 159 + } 160 + 161 + #[test] 162 + fn sanitize_did_replaces_colons() { 163 + assert_eq!(sanitize_did("did:plc:abc123"), "did_plc_abc123"); 164 + } 165 + 166 + #[test] 167 + fn sanitize_did_handles_did_web() { 168 + assert_eq!(sanitize_did("did:web:example.com"), "did_web_example.com"); 169 + } 170 + 171 + #[test] 172 + fn account_dir_uses_sanitized_did() { 173 + with_test_dir(|_| { 174 + let dir = account_dir("did:plc:test"); 175 + assert!(dir.ends_with("accounts/did_plc_test")); 176 + }); 177 + } 178 + 179 + #[test] 180 + fn save_and_load_account_json_roundtrip() { 181 + with_test_dir(|_| { 182 + let did = "did:plc:test"; 183 + let data = serde_json::json!({"key": "value"}); 184 + save_account_json(did, "test.json", &data).unwrap(); 185 + 186 + let loaded: serde_json::Value = load_account_json(did, "test.json").unwrap(); 187 + assert_eq!(loaded["key"], "value"); 188 + }); 189 + } 190 + 191 + #[test] 192 + fn load_account_json_missing_file_errors() { 193 + with_test_dir(|_| { 194 + let result: anyhow::Result<serde_json::Value> = 195 + load_account_json("did:plc:nobody", "nope.json"); 196 + let err = result.unwrap_err().to_string(); 197 + assert!(err.contains("opake login"), "expected login hint: {err}"); 198 + }); 199 + } 200 + 201 + #[test] 202 + fn ensure_account_dir_creates_nested_dirs() { 203 + with_test_dir(|_| { 204 + let did = "did:plc:nested"; 205 + ensure_account_dir(did).unwrap(); 206 + assert!(account_dir(did).exists()); 79 207 }); 80 208 } 81 209 ··· 111 239 } 112 240 113 241 #[test] 114 - fn load_config_rejects_valid_toml_wrong_schema() { 242 + fn load_config_ignores_unknown_keys() { 115 243 with_test_dir(|_| { 116 244 ensure_data_dir().unwrap(); 117 245 fs::write(data_dir().join("config.toml"), "[section]\nkey = 42\n").unwrap(); 118 - let result = load_config(); 119 - assert!(result.is_err()); 246 + // New Config has all optional/default fields — unknown keys are ignored 247 + let loaded = load_config().unwrap(); 248 + assert!(loaded.default_did.is_none()); 249 + assert!(loaded.accounts.is_empty()); 120 250 }); 121 251 } 122 252 123 253 #[test] 124 - fn load_config_rejects_empty_file() { 254 + fn load_config_empty_file_gives_defaults() { 125 255 with_test_dir(|_| { 126 256 ensure_data_dir().unwrap(); 127 257 fs::write(data_dir().join("config.toml"), "").unwrap(); 128 - let result = load_config(); 129 - assert!(result.is_err()); 258 + let loaded = load_config().unwrap(); 259 + assert!(loaded.default_did.is_none()); 260 + assert!(loaded.accounts.is_empty()); 130 261 }); 131 262 } 132 263
+72 -53
crates/opake-cli/src/identity.rs
··· 9 9 10 10 use crate::config; 11 11 12 - const FILENAME: &str = "identity.json"; 13 - 14 12 /// X25519 encryption keypair, stored as base64 in `identity.json`. 15 13 #[derive(Debug, Serialize, Deserialize)] 16 14 pub struct Identity { ··· 41 39 } 42 40 } 43 41 44 - pub fn save_identity(identity: &Identity) -> anyhow::Result<()> { 45 - config::save_json(FILENAME, identity) 42 + pub fn save_identity(did: &str, identity: &Identity) -> anyhow::Result<()> { 43 + config::save_account_json(did, "identity.json", identity) 46 44 } 47 45 48 - pub fn load_identity() -> anyhow::Result<Identity> { 49 - config::load_json(FILENAME) 46 + pub fn load_identity(did: &str) -> anyhow::Result<Identity> { 47 + config::load_account_json(did, "identity.json") 50 48 } 51 49 52 - /// Return the existing identity if it matches `did`, otherwise generate a new 53 - /// X25519 keypair, save it, and return it. The boolean indicates whether a new 54 - /// keypair was generated. 50 + /// Load identity for the default account. 51 + pub fn load_identity_default() -> anyhow::Result<Identity> { 52 + let config = config::load_config()?; 53 + let did = config 54 + .default_did 55 + .ok_or_else(|| anyhow::anyhow!("no default account: run `opake login` first"))?; 56 + load_identity(&did) 57 + } 58 + 59 + /// Return the existing identity if present, otherwise generate a new 60 + /// X25519 keypair, save it, and return it. The boolean indicates whether 61 + /// a new keypair was generated. 55 62 pub fn ensure_identity( 56 63 did: &str, 57 64 rng: &mut (impl CryptoRng + RngCore), 58 65 ) -> anyhow::Result<(Identity, bool)> { 59 - if let Ok(existing) = load_identity() { 66 + if let Ok(existing) = load_identity(did) { 60 67 if existing.did == did { 61 68 return Ok((existing, false)); 62 69 } ··· 74 81 public_key: BASE64.encode(public_key.as_bytes()), 75 82 private_key: BASE64.encode(private_secret.to_bytes()), 76 83 }; 77 - save_identity(&identity)?; 84 + save_identity(did, &identity)?; 78 85 Ok((identity, true)) 79 86 } 80 87 ··· 83 90 use super::*; 84 91 use crate::utils::test_harness::with_test_dir; 85 92 use opake_core::crypto::OsRng; 86 - use std::fs; 93 + use std::collections::BTreeMap; 87 94 88 - fn file_path() -> std::path::PathBuf { 89 - config::data_dir().join(FILENAME) 95 + fn setup_account(did: &str) { 96 + let mut accounts = BTreeMap::new(); 97 + accounts.insert( 98 + did.to_string(), 99 + config::AccountConfig { 100 + pds_url: "https://pds.test".into(), 101 + handle: "test.handle".into(), 102 + }, 103 + ); 104 + config::save_config(&config::Config { 105 + default_did: Some(did.to_string()), 106 + accounts, 107 + }) 108 + .unwrap(); 90 109 } 91 110 92 111 #[test] 93 112 fn save_and_load_identity_roundtrip() { 94 113 with_test_dir(|_| { 114 + let did = "did:plc:test"; 115 + setup_account(did); 95 116 let identity = Identity { 96 - did: "did:plc:test".into(), 117 + did: did.into(), 97 118 public_key: BASE64.encode([1u8; 32]), 98 119 private_key: BASE64.encode([2u8; 32]), 99 120 }; 100 - save_identity(&identity).unwrap(); 121 + save_identity(did, &identity).unwrap(); 101 122 102 - let loaded = load_identity().unwrap(); 123 + let loaded = load_identity(did).unwrap(); 103 124 assert_eq!(loaded.did, identity.did); 104 125 assert_eq!(loaded.public_key, identity.public_key); 105 126 assert_eq!(loaded.private_key, identity.private_key); ··· 112 133 #[test] 113 134 fn ensure_identity_generates_when_missing() { 114 135 with_test_dir(|_| { 115 - let (identity, generated) = ensure_identity("did:plc:new", &mut OsRng).unwrap(); 136 + let did = "did:plc:new"; 137 + setup_account(did); 138 + let (identity, generated) = ensure_identity(did, &mut OsRng).unwrap(); 116 139 assert!(generated); 117 - assert_eq!(identity.did, "did:plc:new"); 140 + assert_eq!(identity.did, did); 118 141 assert_eq!(identity.public_key_bytes().unwrap().len(), 32); 119 142 assert_eq!(identity.private_key_bytes().unwrap().len(), 32); 120 143 }); ··· 123 146 #[test] 124 147 fn ensure_identity_returns_existing_when_did_matches() { 125 148 with_test_dir(|_| { 126 - let (first, generated) = ensure_identity("did:plc:same", &mut OsRng).unwrap(); 149 + let did = "did:plc:same"; 150 + setup_account(did); 151 + let (first, generated) = ensure_identity(did, &mut OsRng).unwrap(); 127 152 assert!(generated); 128 153 129 - let (second, generated) = ensure_identity("did:plc:same", &mut OsRng).unwrap(); 154 + let (second, generated) = ensure_identity(did, &mut OsRng).unwrap(); 130 155 assert!(!generated); 131 156 assert_eq!(first.public_key, second.public_key); 132 157 assert_eq!(first.private_key, second.private_key); ··· 134 159 } 135 160 136 161 #[test] 137 - fn ensure_identity_regenerates_on_did_mismatch() { 162 + fn load_identity_default_works() { 138 163 with_test_dir(|_| { 139 - let (first, _) = ensure_identity("did:plc:alice", &mut OsRng).unwrap(); 140 - let (second, generated) = ensure_identity("did:plc:bob", &mut OsRng).unwrap(); 141 - assert!(generated); 142 - assert_eq!(second.did, "did:plc:bob"); 143 - assert_ne!(first.public_key, second.public_key); 164 + let did = "did:plc:default"; 165 + setup_account(did); 166 + let (_, _) = ensure_identity(did, &mut OsRng).unwrap(); 167 + let loaded = load_identity_default().unwrap(); 168 + assert_eq!(loaded.did, did); 144 169 }); 145 170 } 146 171 147 172 #[test] 148 173 fn load_identity_rejects_garbage_json() { 149 174 with_test_dir(|_| { 150 - config::ensure_data_dir().unwrap(); 151 - fs::write(file_path(), "not json {{{").unwrap(); 152 - assert!(load_identity().is_err()); 175 + let did = "did:plc:test"; 176 + setup_account(did); 177 + config::ensure_account_dir(did).unwrap(); 178 + std::fs::write( 179 + config::account_dir(did).join("identity.json"), 180 + "not json {{{", 181 + ) 182 + .unwrap(); 183 + assert!(load_identity(did).is_err()); 153 184 }); 154 185 } 155 186 156 187 #[test] 157 188 fn load_identity_rejects_valid_json_wrong_schema() { 158 189 with_test_dir(|_| { 159 - config::ensure_data_dir().unwrap(); 160 - fs::write(file_path(), r#"{"color": "blue"}"#).unwrap(); 161 - assert!(load_identity().is_err()); 162 - }); 163 - } 164 - 165 - #[test] 166 - fn load_identity_rejects_empty_file() { 167 - with_test_dir(|_| { 168 - config::ensure_data_dir().unwrap(); 169 - fs::write(file_path(), "").unwrap(); 170 - assert!(load_identity().is_err()); 171 - }); 172 - } 173 - 174 - #[test] 175 - fn load_identity_rejects_binary_noise() { 176 - with_test_dir(|_| { 177 - config::ensure_data_dir().unwrap(); 178 - fs::write(file_path(), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 179 - assert!(load_identity().is_err()); 190 + let did = "did:plc:test"; 191 + setup_account(did); 192 + config::ensure_account_dir(did).unwrap(); 193 + std::fs::write( 194 + config::account_dir(did).join("identity.json"), 195 + r#"{"color": "blue"}"#, 196 + ) 197 + .unwrap(); 198 + assert!(load_identity(did).is_err()); 180 199 }); 181 200 } 182 201 ··· 204 223 fn public_key_bytes_rejects_wrong_length() { 205 224 let identity = Identity { 206 225 did: "did:plc:test".into(), 207 - public_key: BASE64.encode([0u8; 16]), // 16 bytes, not 32 226 + public_key: BASE64.encode([0u8; 16]), 208 227 private_key: BASE64.encode([0u8; 32]), 209 228 }; 210 229 let err = identity.public_key_bytes().unwrap_err().to_string(); ··· 216 235 let identity = Identity { 217 236 did: "did:plc:test".into(), 218 237 public_key: BASE64.encode([0u8; 32]), 219 - private_key: BASE64.encode([0u8; 64]), // 64 bytes, not 32 238 + private_key: BASE64.encode([0u8; 64]), 220 239 }; 221 240 let err = identity.private_key_bytes().unwrap_err().to_string(); 222 241 assert!(err.contains("64 bytes"), "expected length in error: {err}");
+1 -1
crates/opake-cli/src/main.rs
··· 40 40 }; 41 41 42 42 if let Some(ref s) = refreshed { 43 - session::persist_session(s)?; 43 + session::persist_session(&s.did, s)?; 44 44 } 45 45 46 46 Ok(())
+176 -37
crates/opake-cli/src/session.rs
··· 4 4 use crate::config; 5 5 use crate::transport::ReqwestTransport; 6 6 7 - const FILENAME: &str = "session.json"; 7 + /// Resolved account context passed to every command. 8 + #[derive(Debug)] 9 + #[allow(dead_code)] // pds_url used once commands migrate to CommandContext (#52) 10 + pub struct CommandContext { 11 + pub did: String, 12 + pub pds_url: String, 13 + } 14 + 15 + /// Resolve `--as` flag (or default account) to a CommandContext. 16 + pub fn resolve_context(as_flag: Option<&str>) -> anyhow::Result<CommandContext> { 17 + let config = config::load_config()?; 18 + 19 + let did = match as_flag { 20 + Some(value) if value.starts_with("did:") => value.to_string(), 21 + Some(handle) => config 22 + .accounts 23 + .iter() 24 + .find(|(_, acc)| acc.handle == handle) 25 + .map(|(did, _)| did.clone()) 26 + .ok_or_else(|| anyhow::anyhow!("no account with handle {handle}"))?, 27 + None => config 28 + .default_did 29 + .ok_or_else(|| anyhow::anyhow!("no default account: run `opake login` first"))?, 30 + }; 8 31 9 - fn load_session() -> anyhow::Result<Session> { 10 - config::load_json(FILENAME) 32 + let account = config 33 + .accounts 34 + .get(&did) 35 + .ok_or_else(|| anyhow::anyhow!("no account for {did}"))?; 36 + 37 + Ok(CommandContext { 38 + did, 39 + pds_url: account.pds_url.clone(), 40 + }) 11 41 } 12 42 13 - /// Restore a saved session and build an authenticated XRPC client. 14 - pub fn load_client() -> anyhow::Result<XrpcClient<ReqwestTransport>> { 43 + /// Restore a saved session and build an authenticated XRPC client for a specific account. 44 + pub fn load_client(did: &str) -> anyhow::Result<XrpcClient<ReqwestTransport>> { 15 45 let config = config::load_config()?; 16 - let session = load_session()?; 46 + let account = config 47 + .accounts 48 + .get(did) 49 + .ok_or_else(|| anyhow::anyhow!("no account for {did}: run `opake login` first"))?; 50 + let session: Session = config::load_account_json(did, "session.json")?; 17 51 let transport = ReqwestTransport::new(); 18 - Ok(XrpcClient::with_session(transport, config.pds_url, session)) 52 + Ok(XrpcClient::with_session( 53 + transport, 54 + account.pds_url.clone(), 55 + session, 56 + )) 57 + } 58 + 59 + /// Load a client for the default account. Convenience for commands 60 + /// that haven't been migrated to CommandContext yet. 61 + pub fn load_client_default() -> anyhow::Result<XrpcClient<ReqwestTransport>> { 62 + let ctx = resolve_context(None)?; 63 + load_client(&ctx.did) 19 64 } 20 65 21 66 /// Extract the session if it was refreshed during this client's lifetime. ··· 28 73 } 29 74 } 30 75 31 - /// Persist a refreshed session to disk. Called once from the dispatch layer. 32 - pub fn persist_session(session: &Session) -> anyhow::Result<()> { 33 - config::save_json(FILENAME, session)?; 34 - info!("persisted refreshed session tokens"); 76 + /// Persist a refreshed session to disk for a specific account. 77 + pub fn persist_session(did: &str, session: &Session) -> anyhow::Result<()> { 78 + config::save_account_json(did, "session.json", session)?; 79 + info!("persisted refreshed session tokens for {}", did); 35 80 Ok(()) 36 81 } 37 82 ··· 39 84 mod tests { 40 85 use super::*; 41 86 use crate::utils::test_harness::with_test_dir; 87 + use std::collections::BTreeMap; 42 88 use std::fs; 43 89 44 90 fn fake_session() -> Session { ··· 50 96 } 51 97 } 52 98 53 - fn file_path() -> std::path::PathBuf { 54 - config::data_dir().join(FILENAME) 99 + fn setup_account(did: &str, pds_url: &str, handle: &str) { 100 + let mut accounts = BTreeMap::new(); 101 + accounts.insert( 102 + did.to_string(), 103 + config::AccountConfig { 104 + pds_url: pds_url.into(), 105 + handle: handle.into(), 106 + }, 107 + ); 108 + config::save_config(&config::Config { 109 + default_did: Some(did.to_string()), 110 + accounts, 111 + }) 112 + .unwrap(); 55 113 } 56 114 57 115 #[test] 58 116 fn persist_and_load_session_roundtrip() { 59 117 with_test_dir(|_| { 118 + let did = "did:plc:test123"; 119 + setup_account(did, "https://pds.test", "alice.test"); 60 120 let session = fake_session(); 61 - persist_session(&session).unwrap(); 121 + persist_session(did, &session).unwrap(); 62 122 63 - let loaded = load_session().unwrap(); 123 + let loaded: Session = config::load_account_json(did, "session.json").unwrap(); 64 124 assert_eq!(loaded.did, session.did); 65 125 assert_eq!(loaded.handle, session.handle); 66 126 assert_eq!(loaded.access_jwt, session.access_jwt); ··· 71 131 #[test] 72 132 fn load_client_without_session_errors() { 73 133 with_test_dir(|_| { 74 - assert!(load_client().is_err()); 134 + assert!(load_client("did:plc:nobody").is_err()); 135 + }); 136 + } 137 + 138 + #[test] 139 + fn load_client_default_without_config_errors() { 140 + with_test_dir(|_| { 141 + assert!(load_client_default().is_err()); 142 + }); 143 + } 144 + 145 + #[test] 146 + fn resolve_context_uses_default_did() { 147 + with_test_dir(|_| { 148 + setup_account("did:plc:alice", "https://pds.alice", "alice.test"); 149 + let ctx = resolve_context(None).unwrap(); 150 + assert_eq!(ctx.did, "did:plc:alice"); 151 + assert_eq!(ctx.pds_url, "https://pds.alice"); 75 152 }); 76 153 } 77 154 78 155 #[test] 79 - fn load_session_rejects_garbage_json() { 156 + fn resolve_context_with_did_flag() { 80 157 with_test_dir(|_| { 81 - config::ensure_data_dir().unwrap(); 82 - fs::write(file_path(), "not json at all {{{").unwrap(); 83 - assert!(load_session().is_err()); 158 + let mut accounts = BTreeMap::new(); 159 + accounts.insert( 160 + "did:plc:alice".into(), 161 + config::AccountConfig { 162 + pds_url: "https://pds.alice".into(), 163 + handle: "alice.test".into(), 164 + }, 165 + ); 166 + accounts.insert( 167 + "did:plc:bob".into(), 168 + config::AccountConfig { 169 + pds_url: "https://pds.bob".into(), 170 + handle: "bob.test".into(), 171 + }, 172 + ); 173 + config::save_config(&config::Config { 174 + default_did: Some("did:plc:alice".into()), 175 + accounts, 176 + }) 177 + .unwrap(); 178 + 179 + let ctx = resolve_context(Some("did:plc:bob")).unwrap(); 180 + assert_eq!(ctx.did, "did:plc:bob"); 181 + assert_eq!(ctx.pds_url, "https://pds.bob"); 84 182 }); 85 183 } 86 184 87 185 #[test] 88 - fn load_session_rejects_valid_json_wrong_schema() { 186 + fn resolve_context_with_handle_flag() { 89 187 with_test_dir(|_| { 90 - config::ensure_data_dir().unwrap(); 91 - fs::write(file_path(), r#"{"name": "bob", "age": 42}"#).unwrap(); 92 - assert!(load_session().is_err()); 188 + let mut accounts = BTreeMap::new(); 189 + accounts.insert( 190 + "did:plc:alice".into(), 191 + config::AccountConfig { 192 + pds_url: "https://pds.alice".into(), 193 + handle: "alice.test".into(), 194 + }, 195 + ); 196 + accounts.insert( 197 + "did:plc:bob".into(), 198 + config::AccountConfig { 199 + pds_url: "https://pds.bob".into(), 200 + handle: "bob.test".into(), 201 + }, 202 + ); 203 + config::save_config(&config::Config { 204 + default_did: Some("did:plc:alice".into()), 205 + accounts, 206 + }) 207 + .unwrap(); 208 + 209 + let ctx = resolve_context(Some("bob.test")).unwrap(); 210 + assert_eq!(ctx.did, "did:plc:bob"); 93 211 }); 94 212 } 95 213 96 214 #[test] 97 - fn load_session_rejects_empty_file() { 215 + fn resolve_context_unknown_handle_errors() { 216 + with_test_dir(|_| { 217 + setup_account("did:plc:alice", "https://pds.alice", "alice.test"); 218 + let err = resolve_context(Some("nobody.test")).unwrap_err(); 219 + assert!(err.to_string().contains("nobody.test")); 220 + }); 221 + } 222 + 223 + #[test] 224 + fn resolve_context_no_default_errors() { 98 225 with_test_dir(|_| { 99 - config::ensure_data_dir().unwrap(); 100 - fs::write(file_path(), "").unwrap(); 101 - assert!(load_session().is_err()); 226 + config::save_config(&config::Config { 227 + default_did: None, 228 + accounts: BTreeMap::new(), 229 + }) 230 + .unwrap(); 231 + let err = resolve_context(None).unwrap_err(); 232 + assert!(err.to_string().contains("opake login")); 102 233 }); 103 234 } 104 235 105 236 #[test] 106 - fn load_session_rejects_binary_noise() { 237 + fn load_session_rejects_garbage_json() { 107 238 with_test_dir(|_| { 108 - config::ensure_data_dir().unwrap(); 109 - fs::write(file_path(), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 110 - assert!(load_session().is_err()); 239 + let did = "did:plc:test"; 240 + setup_account(did, "https://pds.test", "test.handle"); 241 + config::ensure_account_dir(did).unwrap(); 242 + fs::write( 243 + config::account_dir(did).join("session.json"), 244 + "not json {{{", 245 + ) 246 + .unwrap(); 247 + assert!(load_client(did).is_err()); 111 248 }); 112 249 } 113 250 114 251 #[test] 115 - fn load_session_rejects_partial_session() { 252 + fn load_session_rejects_valid_json_wrong_schema() { 116 253 with_test_dir(|_| { 117 - config::ensure_data_dir().unwrap(); 254 + let did = "did:plc:test"; 255 + setup_account(did, "https://pds.test", "test.handle"); 256 + config::ensure_account_dir(did).unwrap(); 118 257 fs::write( 119 - file_path(), 120 - r#"{"did": "did:plc:x", "handle": "a", "accessJwt": "tok"}"#, 258 + config::account_dir(did).join("session.json"), 259 + r#"{"name": "bob", "age": 42}"#, 121 260 ) 122 261 .unwrap(); 123 - assert!(load_session().is_err()); 262 + assert!(load_client(did).is_err()); 124 263 }); 125 264 } 126 265 }
+61
crates/opake-core/src/records.rs
··· 143 143 } 144 144 145 145 // --------------------------------------------------------------------------- 146 + // app.opake.cloud.publicKey 147 + // --------------------------------------------------------------------------- 148 + 149 + pub const PUBLIC_KEY_COLLECTION: &str = "app.opake.cloud.publicKey"; 150 + pub const PUBLIC_KEY_RKEY: &str = "self"; 151 + 152 + /// Singleton public key record published on the user's PDS. 153 + /// Uses rkey "self" (like app.bsky.actor.profile). 154 + #[derive(Debug, Clone, Serialize, Deserialize)] 155 + #[serde(rename_all = "camelCase")] 156 + pub struct PublicKeyRecord { 157 + #[serde(default = "default_version")] 158 + pub version: u32, 159 + pub public_key: AtBytes, 160 + pub algo: String, 161 + pub created_at: String, 162 + } 163 + 164 + impl PublicKeyRecord { 165 + pub fn new(public_key_bytes: &[u8], created_at: &str) -> Self { 166 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 167 + Self { 168 + version: SCHEMA_VERSION, 169 + public_key: AtBytes { 170 + encoded: BASE64.encode(public_key_bytes), 171 + }, 172 + algo: "x25519".into(), 173 + created_at: created_at.into(), 174 + } 175 + } 176 + } 177 + 178 + // --------------------------------------------------------------------------- 146 179 // app.opake.cloud.grant 147 180 // --------------------------------------------------------------------------- 148 181 ··· 207 240 #[test] 208 241 fn check_version_rejects_max() { 209 242 assert!(check_version(u32::MAX).is_err()); 243 + } 244 + 245 + #[test] 246 + fn public_key_record_new_sets_defaults() { 247 + let record = PublicKeyRecord::new(&[42u8; 32], "2026-03-01T00:00:00Z"); 248 + assert_eq!(record.version, SCHEMA_VERSION); 249 + assert_eq!(record.algo, "x25519"); 250 + assert_eq!(record.created_at, "2026-03-01T00:00:00Z"); 251 + } 252 + 253 + #[test] 254 + fn public_key_record_roundtrips_through_json() { 255 + let record = PublicKeyRecord::new(&[7u8; 32], "2026-03-01T12:00:00Z"); 256 + let json = serde_json::to_string(&record).unwrap(); 257 + let parsed: PublicKeyRecord = serde_json::from_str(&json).unwrap(); 258 + 259 + assert_eq!(parsed.version, record.version); 260 + assert_eq!(parsed.public_key.encoded, record.public_key.encoded); 261 + assert_eq!(parsed.algo, "x25519"); 262 + assert_eq!(parsed.created_at, "2026-03-01T12:00:00Z"); 263 + } 264 + 265 + #[test] 266 + fn public_key_record_uses_atbytes_wire_format() { 267 + let record = PublicKeyRecord::new(&[1u8; 32], "2026-03-01T00:00:00Z"); 268 + let json = serde_json::to_value(&record).unwrap(); 269 + // atproto $bytes convention: { "$bytes": "<base64>" } 270 + assert!(json["publicKey"]["$bytes"].is_string()); 210 271 } 211 272 }
+37
lexicons/app.opake.cloud.publicKey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.opake.cloud.publicKey", 4 + "description": "X25519 encryption public key for an Opake user. Singleton record (rkey: 'self') published on the user's PDS to enable key discovery for sharing.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["version", "publicKey", "algo", "createdAt"], 12 + "properties": { 13 + "version": { 14 + "type": "integer", 15 + "minimum": 1, 16 + "description": "Schema version for forward compatibility." 17 + }, 18 + "publicKey": { 19 + "type": "bytes", 20 + "maxLength": 32, 21 + "description": "Raw X25519 public key bytes." 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 the key was created." 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }

History

1 round 0 comments
sign up or login to add to the discussion
sans-self.org submitted #0
2 commits
expand
Add publicKey lexicon and PublicKeyRecord struct
Add multi-account config and per-account storage
1/1 success
expand
expand 0 comments
pull request successfully merged