An encrypted personal cloud built on the AT Protocol.

Extract Storage trait into opake-core, fix delete_account semantics and error bridging

Move config, identity, and session types plus the Storage trait from
opake-cli into opake-core so the web frontend shares the same contract.
FileStorage (CLI/filesystem) and IndexedDbStorage (browser/Dexie.js)
both implement it.

- Rename trait method delete_account → remove_account with full
semantics: config mutation + data cleanup + persistence
- Config::set_default uses validated BTreeMap key instead of
re-stringifying the input DID
- Remove 13 redundant .map_err(|e| anyhow::anyhow!("{e}")) calls —
opake_core::Error implements std::error::Error via thiserror so ?
converts directly into anyhow::Error
- IndexedDbStorage.removeAccount mutates config atomically in a
single transaction across all three stores
- CLI commands accept FileStorage by reference, no global state

sans-self.org 4c3a4329 86fc5149

Waiting for spindle ...
+1881 -1155
+25 -26
crates/opake-cli/src/commands/accounts.rs
··· 1 1 use anyhow::Result; 2 2 use clap::Args; 3 3 4 - use crate::config; 4 + use crate::config::FileStorage; 5 5 6 6 #[derive(Args)] 7 7 /// List all logged-in accounts 8 8 pub struct AccountsCommand {} 9 9 10 10 impl AccountsCommand { 11 - pub fn run(self) -> Result<()> { 12 - let config = config::load_config()?; 11 + pub fn run(self, storage: &FileStorage) -> Result<()> { 12 + let config = storage.load_config_anyhow()?; 13 13 14 14 if config.accounts.is_empty() { 15 15 println!("no accounts — run `opake login` to add one"); ··· 36 36 mod tests { 37 37 use super::*; 38 38 use crate::config::{AccountConfig, Config}; 39 - use crate::utils::test_harness::with_test_dir; 39 + use crate::utils::test_harness::test_storage; 40 40 use std::collections::BTreeMap; 41 41 42 42 #[test] 43 43 fn run_with_no_config_errors() { 44 - with_test_dir(|_| { 45 - let cmd = AccountsCommand {}; 46 - assert!(cmd.run().is_err()); 47 - }); 44 + let (_dir, storage) = test_storage(); 45 + let cmd = AccountsCommand {}; 46 + assert!(cmd.run(&storage).is_err()); 48 47 } 49 48 50 49 #[test] 51 50 fn run_with_empty_accounts_succeeds() { 52 - with_test_dir(|_| { 53 - config::save_config(&Config { 51 + let (_dir, storage) = test_storage(); 52 + storage 53 + .save_config_anyhow(&Config { 54 54 default_did: None, 55 55 accounts: BTreeMap::new(), 56 56 appview_url: None, 57 57 }) 58 58 .unwrap(); 59 59 60 - let cmd = AccountsCommand {}; 61 - cmd.run().unwrap(); 62 - }); 60 + let cmd = AccountsCommand {}; 61 + cmd.run(&storage).unwrap(); 63 62 } 64 63 65 64 #[test] 66 65 fn run_with_accounts_succeeds() { 67 - with_test_dir(|_| { 68 - let mut accounts = BTreeMap::new(); 69 - accounts.insert( 70 - "did:plc:alice".into(), 71 - AccountConfig { 72 - pds_url: "https://pds.alice".into(), 73 - handle: "alice.test".into(), 74 - }, 75 - ); 76 - config::save_config(&Config { 66 + let (_dir, storage) = test_storage(); 67 + let mut accounts = BTreeMap::new(); 68 + accounts.insert( 69 + "did:plc:alice".into(), 70 + AccountConfig { 71 + pds_url: "https://pds.alice".into(), 72 + handle: "alice.test".into(), 73 + }, 74 + ); 75 + storage 76 + .save_config_anyhow(&Config { 77 77 default_did: Some("did:plc:alice".into()), 78 78 accounts, 79 79 appview_url: None, 80 80 }) 81 81 .unwrap(); 82 82 83 - let cmd = AccountsCommand {}; 84 - cmd.run().unwrap(); 85 - }); 83 + let cmd = AccountsCommand {}; 84 + cmd.run(&storage).unwrap(); 86 85 } 87 86 }
+4 -2
crates/opake-cli/src/commands/cat.rs
··· 21 21 22 22 impl Execute for CatCommand { 23 23 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 24 - let id = identity::load_identity(&ctx.did).context("run `opake login` first")?; 24 + let id = 25 + identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 25 26 let private_key = id.private_key_bytes()?; 26 - let mut client = session::load_client(&ctx.did)?; 27 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 27 28 28 29 // Resolve the reference to an AT-URI. 29 30 let uri = if self.reference.starts_with("at://") { ··· 50 51 let kr_uri = atproto::parse_at_uri(&kr_enc.keyring_ref.keyring)?; 51 52 Some( 52 53 keyring_store::load_group_key( 54 + &ctx.storage, 53 55 &ctx.did, 54 56 &kr_uri.rkey, 55 57 kr_enc.keyring_ref.rotation,
+11 -3
crates/opake-cli/src/commands/download.rs
··· 54 54 55 55 impl Execute for DownloadCommand { 56 56 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 57 - let id = identity::load_identity(&ctx.did).context("run `opake login` first")?; 57 + let id = 58 + identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 58 59 let private_key = id.private_key_bytes()?; 59 60 60 61 let (name, plaintext, refreshed) = if let Some(grant_uri) = &self.grant { ··· 72 73 73 74 // Cache the group key so subsequent downloads use the local path 74 75 let kr_rkey = &result.keyring_rkey; 75 - keyring_store::save_group_key(&ctx.did, kr_rkey, result.rotation, &result.group_key)?; 76 + keyring_store::save_group_key( 77 + &ctx.storage, 78 + &ctx.did, 79 + kr_rkey, 80 + result.rotation, 81 + &result.group_key, 82 + )?; 76 83 77 84 (result.filename, result.plaintext, None) 78 85 } else { ··· 81 88 .reference 82 89 .as_deref() 83 90 .ok_or_else(|| anyhow::anyhow!("provide a document reference or --grant"))?; 84 - let mut client = session::load_client(&ctx.did)?; 91 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 85 92 let uri = documents::resolve_uri(&mut client, reference).await?; 86 93 87 94 // Peek at the document to check if it uses keyring encryption. ··· 97 104 let kr_uri = atproto::parse_at_uri(&kr_enc.keyring_ref.keyring)?; 98 105 Some( 99 106 keyring_store::load_group_key( 107 + &ctx.storage, 100 108 &ctx.did, 101 109 &kr_uri.rkey, 102 110 kr_enc.keyring_ref.rotation,
+2 -3
crates/opake-cli/src/commands/inbox.rs
··· 3 3 use opake_core::client::{fetch_inbox_all, InboxGrant, Session}; 4 4 5 5 use crate::commands::Execute; 6 - use crate::config; 7 6 use crate::identity; 8 7 use crate::session::CommandContext; 9 8 use crate::transport::ReqwestTransport; ··· 52 51 53 52 impl Execute for InboxCommand { 54 53 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 55 - let appview_url = config::resolve_appview_url(self.appview.as_deref())?; 54 + let appview_url = ctx.storage.resolve_appview_url(self.appview.as_deref())?; 56 55 57 - let id = identity::load_identity(&ctx.did) 56 + let id = identity::load_identity(&ctx.storage, &ctx.did) 58 57 .context("no identity found — run `opake login` first")?; 59 58 let signing_key = id 60 59 .signing_key_bytes()?
+17 -9
crates/opake-cli/src/commands/keyring.rs
··· 76 76 } 77 77 78 78 async fn create(ctx: &CommandContext, args: CreateArgs) -> Result<Option<Session>> { 79 - let mut client = session::load_client(&ctx.did)?; 80 - let id = identity::load_identity(&ctx.did)?; 79 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 80 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 81 81 let owner_pubkey = id.public_key_bytes()?; 82 82 83 83 let params = CreateKeyringParams { ··· 90 90 let (uri, group_key) = keyrings::create_keyring(&mut client, &params, &mut OsRng).await?; 91 91 92 92 let at_uri = atproto::parse_at_uri(&uri)?; 93 - keyring_store::save_group_key(&ctx.did, &at_uri.rkey, 0, &group_key)?; 93 + keyring_store::save_group_key(&ctx.storage, &ctx.did, &at_uri.rkey, 0, &group_key)?; 94 94 95 95 println!("{} → {}", args.name, uri); 96 96 Ok(session::refreshed_session(&client)) 97 97 } 98 98 99 99 async fn ls(ctx: &CommandContext, args: LsArgs) -> Result<Option<Session>> { 100 - let mut client = session::load_client(&ctx.did)?; 100 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 101 101 let entries = keyrings::list_keyrings(&mut client).await?; 102 102 103 103 if entries.is_empty() { ··· 121 121 } 122 122 123 123 async fn add_member(ctx: &CommandContext, args: AddMemberArgs) -> Result<Option<Session>> { 124 - let mut client = session::load_client(&ctx.did)?; 124 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 125 125 126 126 let entry = keyrings::resolve_keyring_uri(&mut client, &args.keyring).await?; 127 127 let at_uri = atproto::parse_at_uri(&entry.uri)?; 128 128 129 - let group_key = keyring_store::load_group_key(&ctx.did, &at_uri.rkey, entry.rotation)?; 129 + let group_key = 130 + keyring_store::load_group_key(&ctx.storage, &ctx.did, &at_uri.rkey, entry.rotation)?; 130 131 131 132 let transport = ReqwestTransport::new(); 132 133 let resolved = resolve::resolve_identity(&transport, &ctx.pds_url, &args.member).await?; ··· 148 149 } 149 150 150 151 async fn remove_member(ctx: &CommandContext, args: RemoveMemberArgs) -> Result<Option<Session>> { 151 - let mut client = session::load_client(&ctx.did)?; 152 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 152 153 153 154 let entry = keyrings::resolve_keyring_uri(&mut client, &args.keyring).await?; 154 155 let at_uri = atproto::parse_at_uri(&entry.uri)?; ··· 159 160 let display = resolved.handle.as_deref().unwrap_or(&resolved.did); 160 161 161 162 if !args.yes { 162 - let id = identity::load_identity(&ctx.did).context("run `opake login` first")?; 163 + let id = 164 + identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 163 165 164 166 // Check they're actually a member before prompting 165 167 let kr_record = client ··· 229 231 ) 230 232 .await?; 231 233 232 - keyring_store::save_group_key(&ctx.did, &at_uri.rkey, new_rotation, &new_group_key)?; 234 + keyring_store::save_group_key( 235 + &ctx.storage, 236 + &ctx.did, 237 + &at_uri.rkey, 238 + new_rotation, 239 + &new_group_key, 240 + )?; 233 241 234 242 println!("removed {} from {} (key rotated)", display, args.keyring); 235 243 Ok(session::refreshed_session(&client))
+6 -18
crates/opake-cli/src/commands/login.rs
··· 4 4 use log::debug; 5 5 use opake_core::client::{Session, XrpcClient}; 6 6 7 - use crate::config::{self, AccountConfig}; 7 + use crate::config::{AccountConfig, FileStorage}; 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; 13 11 14 12 /// Resolve password from env var or a fallback function (e.g. stdin prompt). 15 13 pub fn resolve_password( ··· 45 43 } 46 44 47 45 impl LoginCommand { 48 - pub async fn execute(self) -> Result<Option<Session>> { 46 + pub async fn execute(self, storage: &FileStorage) -> Result<Option<Session>> { 49 47 debug!("Starting login command"); 50 48 51 49 let password = resolve_password(prefixed_get_env("PASSWORD"), || { ··· 60 58 .await? 61 59 .clone(); 62 60 63 - // Register this account in the config. Merge into existing accounts 64 - // if present, set as default if it's the first one. 65 - let mut cfg = config::load_config().unwrap_or(config::Config { 66 - default_did: None, 67 - accounts: BTreeMap::new(), 68 - appview_url: None, 69 - }); 61 + let mut cfg = storage.load_config_anyhow().unwrap_or_default(); 70 62 71 - cfg.accounts.insert( 63 + cfg.add_account( 72 64 session.did.clone(), 73 65 AccountConfig { 74 66 pds_url: self.pds.clone(), ··· 76 68 }, 77 69 ); 78 70 79 - if cfg.default_did.is_none() { 80 - cfg.default_did = Some(session.did.clone()); 81 - } 82 - 83 - config::save_config(&cfg)?; 71 + storage.save_config_anyhow(&cfg)?; 84 72 85 73 let (identity, generated) = 86 - identity::ensure_identity(&session.did, &mut opake_core::crypto::OsRng)?; 74 + identity::ensure_identity(storage, &session.did, &mut opake_core::crypto::OsRng)?; 87 75 88 76 if generated { 89 77 println!("Generated new encryption keypair");
+39 -39
crates/opake-cli/src/commands/logout.rs
··· 1 1 use anyhow::Result; 2 2 use clap::Args; 3 3 4 - use crate::config; 4 + use crate::config::{resolve_handle_or_did, FileStorage}; 5 5 6 6 #[derive(Args)] 7 7 /// Remove an account ··· 11 11 } 12 12 13 13 impl LogoutCommand { 14 - pub fn run(self) -> Result<()> { 15 - let cfg = config::load_config()?; 16 - let did = config::resolve_handle_or_did(&cfg, &self.account)?; 14 + pub fn run(self, storage: &FileStorage) -> Result<()> { 15 + let cfg = storage.load_config_anyhow()?; 16 + let did = resolve_handle_or_did(&cfg, &self.account)?; 17 17 let handle = cfg 18 18 .accounts 19 19 .get(&did) ··· 22 22 23 23 println!("logging out {} ({})", handle, did); 24 24 25 - config::remove_account(&did)?; 25 + storage.remove_account(&did)?; 26 26 27 27 println!("done"); 28 28 Ok(()) ··· 33 33 mod tests { 34 34 use super::*; 35 35 use crate::config::{AccountConfig, Config}; 36 - use crate::utils::test_harness::with_test_dir; 36 + use crate::utils::test_harness::test_storage; 37 37 use std::collections::BTreeMap; 38 38 39 39 #[test] 40 40 fn logout_removes_account() { 41 - with_test_dir(|_| { 42 - let mut accounts = BTreeMap::new(); 43 - accounts.insert( 44 - "did:plc:alice".into(), 45 - AccountConfig { 46 - pds_url: "https://pds.alice".into(), 47 - handle: "alice.test".into(), 48 - }, 49 - ); 50 - config::save_config(&Config { 41 + let (_dir, storage) = test_storage(); 42 + let mut accounts = BTreeMap::new(); 43 + accounts.insert( 44 + "did:plc:alice".into(), 45 + AccountConfig { 46 + pds_url: "https://pds.alice".into(), 47 + handle: "alice.test".into(), 48 + }, 49 + ); 50 + storage 51 + .save_config_anyhow(&Config { 51 52 default_did: Some("did:plc:alice".into()), 52 53 accounts, 53 54 appview_url: None, 54 55 }) 55 56 .unwrap(); 56 57 57 - let cmd = LogoutCommand { 58 - account: "alice.test".into(), 59 - }; 60 - cmd.run().unwrap(); 58 + let cmd = LogoutCommand { 59 + account: "alice.test".into(), 60 + }; 61 + cmd.run(&storage).unwrap(); 61 62 62 - let loaded = config::load_config().unwrap(); 63 - assert!(loaded.accounts.is_empty()); 64 - assert!(loaded.default_did.is_none()); 65 - }); 63 + let loaded = storage.load_config_anyhow().unwrap(); 64 + assert!(loaded.accounts.is_empty()); 65 + assert!(loaded.default_did.is_none()); 66 66 } 67 67 68 68 #[test] 69 69 fn logout_unknown_handle_errors() { 70 - with_test_dir(|_| { 71 - let mut accounts = BTreeMap::new(); 72 - accounts.insert( 73 - "did:plc:alice".into(), 74 - AccountConfig { 75 - pds_url: "https://pds.alice".into(), 76 - handle: "alice.test".into(), 77 - }, 78 - ); 79 - config::save_config(&Config { 70 + let (_dir, storage) = test_storage(); 71 + let mut accounts = BTreeMap::new(); 72 + accounts.insert( 73 + "did:plc:alice".into(), 74 + AccountConfig { 75 + pds_url: "https://pds.alice".into(), 76 + handle: "alice.test".into(), 77 + }, 78 + ); 79 + storage 80 + .save_config_anyhow(&Config { 80 81 default_did: Some("did:plc:alice".into()), 81 82 accounts, 82 83 appview_url: None, 83 84 }) 84 85 .unwrap(); 85 86 86 - let cmd = LogoutCommand { 87 - account: "nobody.test".into(), 88 - }; 89 - assert!(cmd.run().is_err()); 90 - }); 87 + let cmd = LogoutCommand { 88 + account: "nobody.test".into(), 89 + }; 90 + assert!(cmd.run(&storage).is_err()); 91 91 } 92 92 }
+1 -1
crates/opake-cli/src/commands/ls.rs
··· 73 73 74 74 impl Execute for LsCommand { 75 75 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 76 - let mut client = session::load_client(&ctx.did)?; 76 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 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/mkdir.rs
··· 16 16 17 17 impl Execute for MkdirCommand { 18 18 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 19 - let mut client = session::load_client(&ctx.did)?; 19 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 20 20 let now = Utc::now().to_rfc3339(); 21 21 22 22 let root_uri = directories::get_or_create_root(&mut client, &ctx.did, &now).await?;
+1 -1
crates/opake-cli/src/commands/mv.rs
··· 22 22 23 23 impl Execute for MvCommand { 24 24 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 25 - let mut client = session::load_client(&ctx.did)?; 25 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 26 26 let now = Utc::now().to_rfc3339(); 27 27 28 28 let tree = DirectoryTree::load(&mut client).await?;
+1 -1
crates/opake-cli/src/commands/revoke.rs
··· 19 19 20 20 impl Execute for RevokeCommand { 21 21 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 22 - let mut client = session::load_client(&ctx.did)?; 22 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 23 23 24 24 if !self.yes { 25 25 eprint!("revoke {}? [y/N] ", self.grant);
+1 -1
crates/opake-cli/src/commands/rm.rs
··· 77 77 78 78 impl Execute for RmCommand { 79 79 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 80 - let mut client = session::load_client(&ctx.did)?; 80 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 81 81 let now = Utc::now().to_rfc3339(); 82 82 83 83 let resolution = try_fast_resolve(&mut client, &self.reference).await?;
+37 -43
crates/opake-cli/src/commands/set_default.rs
··· 1 1 use anyhow::Result; 2 2 use clap::Args; 3 3 4 - use crate::config; 4 + use crate::config::{resolve_handle_or_did, FileStorage}; 5 5 6 6 #[derive(Args)] 7 7 /// Set the default account ··· 11 11 } 12 12 13 13 impl SetDefaultCommand { 14 - pub fn run(self) -> Result<()> { 15 - let mut cfg = config::load_config()?; 16 - let did = config::resolve_handle_or_did(&cfg, &self.account)?; 17 - 18 - anyhow::ensure!(cfg.accounts.contains_key(&did), "no account for {did}"); 14 + pub fn run(self, storage: &FileStorage) -> Result<()> { 15 + let mut cfg = storage.load_config_anyhow()?; 16 + let did = resolve_handle_or_did(&cfg, &self.account)?; 19 17 20 - cfg.default_did = Some(did.clone()); 21 - config::save_config(&cfg)?; 18 + cfg.set_default(&did)?; 19 + storage.save_config_anyhow(&cfg)?; 22 20 23 21 let handle = &cfg.accounts[&did].handle; 24 22 println!("default account set to {} ({})", handle, did); ··· 31 29 mod tests { 32 30 use super::*; 33 31 use crate::config::{AccountConfig, Config}; 34 - use crate::utils::test_harness::with_test_dir; 32 + use crate::utils::test_harness::test_storage; 35 33 use std::collections::BTreeMap; 36 34 37 35 fn two_account_config() -> Config { ··· 59 57 60 58 #[test] 61 59 fn set_default_by_handle() { 62 - with_test_dir(|_| { 63 - config::save_config(&two_account_config()).unwrap(); 60 + let (_dir, storage) = test_storage(); 61 + storage.save_config_anyhow(&two_account_config()).unwrap(); 64 62 65 - let cmd = SetDefaultCommand { 66 - account: "bob.test".into(), 67 - }; 68 - cmd.run().unwrap(); 63 + let cmd = SetDefaultCommand { 64 + account: "bob.test".into(), 65 + }; 66 + cmd.run(&storage).unwrap(); 69 67 70 - let loaded = config::load_config().unwrap(); 71 - assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 72 - }); 68 + let loaded = storage.load_config_anyhow().unwrap(); 69 + assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 73 70 } 74 71 75 72 #[test] 76 73 fn set_default_by_did() { 77 - with_test_dir(|_| { 78 - config::save_config(&two_account_config()).unwrap(); 74 + let (_dir, storage) = test_storage(); 75 + storage.save_config_anyhow(&two_account_config()).unwrap(); 79 76 80 - let cmd = SetDefaultCommand { 81 - account: "did:plc:bob".into(), 82 - }; 83 - cmd.run().unwrap(); 77 + let cmd = SetDefaultCommand { 78 + account: "did:plc:bob".into(), 79 + }; 80 + cmd.run(&storage).unwrap(); 84 81 85 - let loaded = config::load_config().unwrap(); 86 - assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 87 - }); 82 + let loaded = storage.load_config_anyhow().unwrap(); 83 + assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 88 84 } 89 85 90 86 #[test] 91 87 fn set_default_unknown_handle_errors() { 92 - with_test_dir(|_| { 93 - config::save_config(&two_account_config()).unwrap(); 88 + let (_dir, storage) = test_storage(); 89 + storage.save_config_anyhow(&two_account_config()).unwrap(); 94 90 95 - let cmd = SetDefaultCommand { 96 - account: "nobody.test".into(), 97 - }; 98 - let err = cmd.run().unwrap_err(); 99 - assert!(err.to_string().contains("nobody.test")); 100 - }); 91 + let cmd = SetDefaultCommand { 92 + account: "nobody.test".into(), 93 + }; 94 + let err = cmd.run(&storage).unwrap_err(); 95 + assert!(err.to_string().contains("nobody.test")); 101 96 } 102 97 103 98 #[test] 104 99 fn set_default_unknown_did_errors() { 105 - with_test_dir(|_| { 106 - config::save_config(&two_account_config()).unwrap(); 100 + let (_dir, storage) = test_storage(); 101 + storage.save_config_anyhow(&two_account_config()).unwrap(); 107 102 108 - let cmd = SetDefaultCommand { 109 - account: "did:plc:unknown".into(), 110 - }; 111 - let err = cmd.run().unwrap_err(); 112 - assert!(err.to_string().contains("did:plc:unknown")); 113 - }); 103 + let cmd = SetDefaultCommand { 104 + account: "did:plc:unknown".into(), 105 + }; 106 + let err = cmd.run(&storage).unwrap_err(); 107 + assert!(err.to_string().contains("did:plc:unknown")); 114 108 } 115 109 }
+3 -2
crates/opake-cli/src/commands/share.rs
··· 28 28 29 29 impl Execute for ShareCommand { 30 30 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 31 - let mut client = session::load_client(&ctx.did)?; 32 - let id = identity::load_identity(&ctx.did).context("run `opake login` first")?; 31 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 32 + let id = 33 + identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 33 34 let private_key = id.private_key_bytes()?; 34 35 35 36 let uri = documents::resolve_uri(&mut client, &self.document).await?;
+1 -1
crates/opake-cli/src/commands/shared.rs
··· 46 46 47 47 impl Execute for SharedCommand { 48 48 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 49 - let mut client = session::load_client(&ctx.did)?; 49 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 50 50 let entries = sharing::list_grants(&mut client).await?; 51 51 52 52 if entries.is_empty() {
+1 -1
crates/opake-cli/src/commands/tree.rs
··· 12 12 13 13 impl Execute for TreeCommand { 14 14 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 15 - let mut client = session::load_client(&ctx.did)?; 15 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 16 16 let (tree, documents) = DirectoryTree::load_full(&mut client).await?; 17 17 18 18 println!("{}", tree.render(&documents));
+12 -3
crates/opake-cli/src/commands/upload.rs
··· 38 38 39 39 impl Execute for UploadCommand { 40 40 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 41 - let mut client = session::load_client(&ctx.did)?; 41 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 42 42 43 43 let plaintext = 44 44 fs::read(&self.path).context(format!("failed to read {}", self.path.display()))?; ··· 58 58 let uri = if let Some(keyring_name) = &self.keyring { 59 59 let entry = keyrings::resolve_keyring_uri(&mut client, keyring_name).await?; 60 60 let at_uri = atproto::parse_at_uri(&entry.uri)?; 61 - let group_key = keyring_store::load_group_key(&ctx.did, &at_uri.rkey, entry.rotation)?; 61 + let group_key = keyring_store::load_group_key( 62 + &ctx.storage, 63 + &ctx.did, 64 + &at_uri.rkey, 65 + entry.rotation, 66 + )?; 62 67 63 68 let params = KeyringUploadParams { 64 69 plaintext: &plaintext, ··· 73 78 74 79 documents::encrypt_and_upload_keyring(&mut client, &params, &mut OsRng).await? 75 80 } else { 76 - let id = identity::load_identity(&ctx.did)?; 81 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 77 82 let owner_pubkey = id.public_key_bytes()?; 78 83 79 84 let params = UploadParams { ··· 110 115 #[cfg(test)] 111 116 mod tests { 112 117 use super::*; 118 + use crate::config::FileStorage; 119 + use crate::utils::test_harness::test_storage; 113 120 114 121 #[test] 115 122 fn rejects_nonexistent_file() { 116 123 let rt = tokio::runtime::Runtime::new().unwrap(); 124 + let (_dir, storage) = test_storage(); 117 125 let cmd = UploadCommand { 118 126 path: PathBuf::from("/tmp/opake-test-nonexistent-file-abc123"), 119 127 keyring: None, ··· 123 131 let ctx = CommandContext { 124 132 did: "did:plc:test".into(), 125 133 pds_url: "https://pds.test".into(), 134 + storage: storage.clone(), 126 135 }; 127 136 let result = rt.block_on(cmd.execute(&ctx)); 128 137 assert!(result.is_err());
+193 -134
crates/opake-cli/src/config.rs
··· 1 - use std::collections::BTreeMap; 2 1 use std::fs; 3 2 use std::os::unix::fs::PermissionsExt; 4 3 use std::path::{Path, PathBuf}; 5 - use std::sync::RwLock; 6 4 7 5 use anyhow::Context; 8 6 use serde::de::DeserializeOwned; 9 - use serde::{Deserialize, Serialize}; 7 + use serde::Serialize; 8 + 9 + use opake_core::client::Session; 10 + use opake_core::error::Error; 11 + use opake_core::storage::Storage; 10 12 11 13 const SENSITIVE_FILE_MODE: u32 = 0o600; 12 14 const SENSITIVE_DIR_MODE: u32 = 0o700; 13 15 14 - static DATA_DIR: RwLock<Option<PathBuf>> = RwLock::new(None); 16 + // --------------------------------------------------------------------------- 17 + // FileStorage 18 + // --------------------------------------------------------------------------- 15 19 16 - /// Resolve and store the data directory. Call once at startup. 17 - /// Priority: override > OPAKE_DATA_DIR env > XDG_CONFIG_HOME/opake > ~/.config/opake 18 - pub fn init_data_dir(override_dir: Option<PathBuf>) { 19 - let dir = opake_core::paths::resolve_data_dir(override_dir); 20 - *DATA_DIR.write().unwrap() = Some(dir); 20 + /// Filesystem-backed storage for config, identity, and session data. 21 + /// One instance per CLI invocation — no global state. 22 + #[derive(Debug, Clone)] 23 + pub struct FileStorage { 24 + base_dir: PathBuf, 21 25 } 22 26 23 - /// Persistent CLI configuration — tracks all logged-in accounts. 24 - #[derive(Debug, Serialize, Deserialize)] 25 - pub struct Config { 26 - pub default_did: Option<String>, 27 - #[serde(default)] 28 - pub accounts: BTreeMap<String, AccountConfig>, 29 - #[serde(default)] 30 - pub appview_url: Option<String>, 31 - } 27 + impl FileStorage { 28 + pub fn new(base_dir: PathBuf) -> Self { 29 + Self { base_dir } 30 + } 32 31 33 - /// Per-account configuration stored in the global config.toml. 34 - #[derive(Debug, Serialize, Deserialize)] 35 - pub struct AccountConfig { 36 - pub pds_url: String, 37 - pub handle: String, 38 - } 32 + #[allow(dead_code)] // used by tests + external callers via FileStorage 33 + pub fn base_dir(&self) -> &Path { 34 + &self.base_dir 35 + } 39 36 40 - /// Write a file and set its permissions to 0600 (owner read/write only). 41 - pub fn write_sensitive_file(path: &Path, content: impl AsRef<[u8]>) -> anyhow::Result<()> { 42 - fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; 43 - fs::set_permissions(path, fs::Permissions::from_mode(SENSITIVE_FILE_MODE)) 44 - .with_context(|| format!("failed to set permissions on {}", path.display())) 45 - } 37 + pub fn account_dir(&self, did: &str) -> PathBuf { 38 + self.base_dir.join("accounts").join(sanitize_did(did)) 39 + } 46 40 47 - /// Create a directory (and parents) with 0700 permissions. 48 - /// Always sets permissions, even on existing dirs, to fix upgrades. 49 - pub fn ensure_sensitive_dir(path: &Path) -> anyhow::Result<()> { 50 - fs::create_dir_all(path) 51 - .with_context(|| format!("failed to create directory: {}", path.display()))?; 52 - fs::set_permissions(path, fs::Permissions::from_mode(SENSITIVE_DIR_MODE)) 53 - .with_context(|| format!("failed to set permissions on {}", path.display())) 54 - } 41 + // -- Platform-specific helpers (not on the trait) ------------------------- 55 42 56 - /// The resolved data directory. Must call `init_data_dir()` before use. 57 - pub fn data_dir() -> PathBuf { 58 - DATA_DIR 59 - .read() 60 - .unwrap() 61 - .clone() 62 - .expect("data_dir not initialized: call init_data_dir() first") 63 - } 43 + /// Write a file and set its permissions to 0600 (owner read/write only). 44 + pub fn write_sensitive_file(path: &Path, content: impl AsRef<[u8]>) -> anyhow::Result<()> { 45 + fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; 46 + fs::set_permissions(path, fs::Permissions::from_mode(SENSITIVE_FILE_MODE)) 47 + .with_context(|| format!("failed to set permissions on {}", path.display())) 48 + } 64 49 65 - /// Create the data directory if it doesn't exist, with 0700 permissions. 66 - pub fn ensure_data_dir() -> anyhow::Result<()> { 67 - ensure_sensitive_dir(&data_dir()) 68 - } 50 + /// Create a directory (and parents) with 0700 permissions. 51 + /// Always sets permissions, even on existing dirs, to fix upgrades. 52 + pub fn ensure_sensitive_dir(path: &Path) -> anyhow::Result<()> { 53 + fs::create_dir_all(path) 54 + .with_context(|| format!("failed to create directory: {}", path.display()))?; 55 + fs::set_permissions(path, fs::Permissions::from_mode(SENSITIVE_DIR_MODE)) 56 + .with_context(|| format!("failed to set permissions on {}", path.display())) 57 + } 69 58 70 - pub fn save_config(config: &Config) -> anyhow::Result<()> { 71 - ensure_data_dir()?; 72 - let content = toml::to_string_pretty(config).context("failed to serialize config")?; 73 - write_sensitive_file(&data_dir().join("config.toml"), content) 74 - } 59 + /// Create the base data directory if it doesn't exist, with 0700 permissions. 60 + pub fn ensure_base_dir(&self) -> anyhow::Result<()> { 61 + Self::ensure_sensitive_dir(&self.base_dir) 62 + } 75 63 76 - pub fn load_config() -> anyhow::Result<Config> { 77 - let path = data_dir().join("config.toml"); 78 - let content = fs::read_to_string(&path) 79 - .with_context(|| format!("no config at {}: run `opake login` first", path.display()))?; 80 - toml::from_str(&content).context("failed to parse config.toml") 81 - } 64 + /// Create the account directory (and parents) with 0700 permissions. 65 + pub fn ensure_account_dir(&self, did: &str) -> anyhow::Result<()> { 66 + Self::ensure_sensitive_dir(&self.account_dir(did)) 67 + } 82 68 83 - /// Make a DID safe for use as a directory name: `did:plc:abc` → `did_plc_abc`. 84 - pub fn sanitize_did(did: &str) -> String { 85 - did.replace(':', "_") 86 - } 69 + /// Bail if identity.json is readable by group or others (like `ssh -o StrictModes`). 70 + pub fn check_identity_permissions(path: &Path) -> anyhow::Result<()> { 71 + if !path.exists() { 72 + return Ok(()); 73 + } 74 + let mode = path.metadata()?.permissions().mode(); 75 + if mode & 0o077 != 0 { 76 + anyhow::bail!( 77 + "permissions {:#o} for '{}' are too open — private key material must not be \ 78 + accessible by other users. Run: chmod 600 {}", 79 + mode & 0o777, 80 + path.display(), 81 + path.display(), 82 + ); 83 + } 84 + Ok(()) 85 + } 87 86 88 - /// Path to an account's private data directory. 89 - pub fn account_dir(did: &str) -> PathBuf { 90 - data_dir().join("accounts").join(sanitize_did(did)) 91 - } 87 + // -- Generic JSON helpers for account data -------------------------------- 88 + 89 + /// Serialize a value to a JSON file inside an account's directory. 90 + pub fn save_account_json<T: Serialize>( 91 + &self, 92 + did: &str, 93 + filename: &str, 94 + value: &T, 95 + ) -> anyhow::Result<()> { 96 + self.ensure_account_dir(did)?; 97 + let json = serde_json::to_string_pretty(value) 98 + .with_context(|| format!("failed to serialize {filename}"))?; 99 + Self::write_sensitive_file(&self.account_dir(did).join(filename), json) 100 + .with_context(|| format!("failed to write {filename} for {did}")) 101 + } 102 + 103 + /// Deserialize a value from a JSON file inside an account's directory. 104 + pub fn load_account_json<T: DeserializeOwned>( 105 + &self, 106 + did: &str, 107 + filename: &str, 108 + ) -> anyhow::Result<T> { 109 + let path = self.account_dir(did).join(filename); 110 + let content = fs::read_to_string(&path) 111 + .with_context(|| format!("no {filename} for {did}: run `opake login` first"))?; 112 + serde_json::from_str(&content) 113 + .with_context(|| format!("failed to parse {filename} for {did}")) 114 + } 115 + 116 + // -- Composite operations (CLI-specific, not on trait) --------------------- 117 + 118 + /// Remove an account: delete from config.accounts, clear default_did if it 119 + /// matched, remove the account's data directory, and save the updated config. 120 + pub fn remove_account(&self, did: &str) -> anyhow::Result<()> { 121 + let mut config = self.load_config_anyhow()?; 92 122 93 - /// Create the account directory (and parents) with 0700 permissions. 94 - pub fn ensure_account_dir(did: &str) -> anyhow::Result<()> { 95 - ensure_sensitive_dir(&account_dir(did)) 96 - } 123 + config.remove_account(did)?; 97 124 98 - /// Serialize a value to a JSON file inside an account's directory. 99 - pub fn save_account_json<T: Serialize>(did: &str, filename: &str, value: &T) -> anyhow::Result<()> { 100 - ensure_account_dir(did)?; 101 - let json = serde_json::to_string_pretty(value) 102 - .with_context(|| format!("failed to serialize {filename}"))?; 103 - write_sensitive_file(&account_dir(did).join(filename), json) 104 - .with_context(|| format!("failed to write {filename} for {did}")) 105 - } 125 + let dir = self.account_dir(did); 126 + if dir.exists() { 127 + fs::remove_dir_all(&dir).with_context(|| { 128 + format!("failed to remove account directory: {}", dir.display()) 129 + })?; 130 + } 106 131 107 - /// Resolve a handle or DID string to a DID. If the input starts with `did:`, 108 - /// it's returned as-is. Otherwise, it's looked up as a handle in the config. 109 - pub fn resolve_handle_or_did(config: &Config, input: &str) -> anyhow::Result<String> { 110 - if input.starts_with("did:") { 111 - return Ok(input.to_string()); 132 + self.save_config_anyhow(&config) 112 133 } 113 - config 114 - .accounts 115 - .iter() 116 - .find(|(_, acc)| acc.handle == input) 117 - .map(|(did, _)| did.clone()) 118 - .ok_or_else(|| anyhow::anyhow!("no account with handle {input}")) 119 - } 120 134 121 - /// Remove an account: delete from config.accounts, clear default_did if it 122 - /// matched, remove the account's data directory, and save the updated config. 123 - pub fn remove_account(did: &str) -> anyhow::Result<()> { 124 - let mut config = load_config()?; 135 + /// Resolve the appview URL from (in priority order): 136 + /// 1. Explicit flag value (`--appview`) 137 + /// 2. `OPAKE_APPVIEW_URL` environment variable 138 + /// 3. `appview_url` field in config 139 + /// 140 + /// Returns a clear error if none are set. 141 + pub fn resolve_appview_url(&self, explicit: Option<&str>) -> anyhow::Result<String> { 142 + if let Some(url) = explicit { 143 + return Ok(url.to_string()); 144 + } 125 145 126 - anyhow::ensure!(config.accounts.contains_key(did), "no account for {did}"); 146 + if let Ok(url) = std::env::var("OPAKE_APPVIEW_URL") { 147 + if !url.is_empty() { 148 + return Ok(url); 149 + } 150 + } 127 151 128 - config.accounts.remove(did); 152 + if let Ok(config) = self.load_config_anyhow() { 153 + if let Some(url) = config.appview_url { 154 + return Ok(url); 155 + } 156 + } 129 157 130 - if config.default_did.as_deref() == Some(did) { 131 - config.default_did = config.accounts.keys().next().cloned(); 158 + anyhow::bail!( 159 + "no appview URL configured — pass --appview <url>, \ 160 + set OPAKE_APPVIEW_URL, or add appview_url to config.toml" 161 + ) 132 162 } 133 163 134 - let dir = account_dir(did); 135 - if dir.exists() { 136 - fs::remove_dir_all(&dir) 137 - .with_context(|| format!("failed to remove account directory: {}", dir.display()))?; 164 + // -- Anyhow wrappers (the trait uses opake_core::Error, CLI wants anyhow) - 165 + 166 + /// Load config using anyhow errors (for CLI callers that don't go through the trait). 167 + pub fn load_config_anyhow(&self) -> anyhow::Result<Config> { 168 + let path = self.base_dir.join("config.toml"); 169 + let content = fs::read_to_string(&path) 170 + .with_context(|| format!("no config at {}: run `opake login` first", path.display()))?; 171 + toml::from_str(&content).context("failed to parse config.toml") 138 172 } 139 173 140 - save_config(&config) 174 + /// Save config using anyhow errors (for CLI callers that don't go through the trait). 175 + pub fn save_config_anyhow(&self, config: &Config) -> anyhow::Result<()> { 176 + self.ensure_base_dir()?; 177 + let content = toml::to_string_pretty(config).context("failed to serialize config")?; 178 + Self::write_sensitive_file(&self.base_dir.join("config.toml"), content) 179 + } 141 180 } 142 181 143 - /// Deserialize a value from a JSON file inside an account's directory. 144 - pub fn load_account_json<T: DeserializeOwned>(did: &str, filename: &str) -> anyhow::Result<T> { 145 - let path = account_dir(did).join(filename); 146 - let content = fs::read_to_string(&path) 147 - .with_context(|| format!("no {filename} for {did}: run `opake login` first"))?; 148 - serde_json::from_str(&content).with_context(|| format!("failed to parse {filename} for {did}")) 149 - } 182 + // --------------------------------------------------------------------------- 183 + // Storage trait implementation 184 + // --------------------------------------------------------------------------- 185 + 186 + impl Storage for FileStorage { 187 + async fn load_config(&self) -> Result<Config, Error> { 188 + self.load_config_anyhow() 189 + .map_err(|e| Error::Storage(e.to_string())) 190 + } 191 + 192 + async fn save_config(&self, config: &Config) -> Result<(), Error> { 193 + self.save_config_anyhow(config) 194 + .map_err(|e| Error::Storage(e.to_string())) 195 + } 196 + 197 + async fn load_identity(&self, did: &str) -> Result<Identity, Error> { 198 + let path = self.account_dir(did).join("identity.json"); 199 + Self::check_identity_permissions(&path).map_err(|e| Error::Storage(e.to_string()))?; 200 + self.load_account_json::<Identity>(did, "identity.json") 201 + .map_err(|e| Error::Storage(e.to_string())) 202 + } 150 203 151 - /// Resolve the appview URL from (in priority order): 152 - /// 1. Explicit flag value (`--appview`) 153 - /// 2. `OPAKE_APPVIEW_URL` environment variable 154 - /// 3. `appview_url` field in config.toml 155 - /// 156 - /// Returns a clear error if none are set. 157 - pub fn resolve_appview_url(explicit: Option<&str>) -> anyhow::Result<String> { 158 - if let Some(url) = explicit { 159 - return Ok(url.to_string()); 204 + async fn save_identity(&self, did: &str, identity: &Identity) -> Result<(), Error> { 205 + self.save_account_json(did, "identity.json", identity) 206 + .map_err(|e| Error::Storage(e.to_string())) 207 + } 208 + 209 + async fn load_session(&self, did: &str) -> Result<Session, Error> { 210 + self.load_account_json::<Session>(did, "session.json") 211 + .map_err(|e| Error::Storage(e.to_string())) 160 212 } 161 213 162 - if let Ok(url) = std::env::var("OPAKE_APPVIEW_URL") { 163 - if !url.is_empty() { 164 - return Ok(url); 165 - } 214 + async fn save_session(&self, did: &str, session: &Session) -> Result<(), Error> { 215 + self.save_account_json(did, "session.json", session) 216 + .map_err(|e| Error::Storage(e.to_string())) 166 217 } 167 218 168 - if let Ok(config) = load_config() { 169 - if let Some(url) = config.appview_url { 170 - return Ok(url); 219 + async fn remove_account(&self, did: &str) -> Result<(), Error> { 220 + let mut config = self 221 + .load_config_anyhow() 222 + .map_err(|e| Error::Storage(e.to_string()))?; 223 + config.remove_account(did)?; 224 + let dir = self.account_dir(did); 225 + if dir.exists() { 226 + fs::remove_dir_all(&dir) 227 + .map_err(|e| Error::Storage(format!("failed to remove account dir: {e}")))?; 171 228 } 229 + self.save_config_anyhow(&config) 230 + .map_err(|e| Error::Storage(e.to_string())) 172 231 } 173 - 174 - anyhow::bail!( 175 - "no appview URL configured — pass --appview <url>, \ 176 - set OPAKE_APPVIEW_URL, or add appview_url to config.toml" 177 - ) 178 232 } 233 + 234 + // Re-export types so existing `use crate::config::*` keeps working. 235 + pub use opake_core::storage::{ 236 + resolve_handle_or_did, sanitize_did, AccountConfig, Config, Identity, 237 + }; 179 238 180 239 #[cfg(test)] 181 240 #[path = "config_tests.rs"]
+220 -229
crates/opake-cli/src/config_tests.rs
··· 1 1 use super::*; 2 - use crate::utils::test_harness::with_test_dir; 2 + use crate::utils::test_harness::test_storage; 3 + use std::collections::BTreeMap; 3 4 4 5 fn test_config(did: &str, pds_url: &str, handle: &str) -> Config { 5 6 let mut accounts = BTreeMap::new(); ··· 19 20 20 21 #[test] 21 22 fn save_and_load_config_roundtrip() { 22 - with_test_dir(|_| { 23 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 24 - save_config(&config).unwrap(); 23 + let (_dir, storage) = test_storage(); 24 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 25 + storage.save_config_anyhow(&config).unwrap(); 25 26 26 - let loaded = load_config().unwrap(); 27 - assert_eq!(loaded.default_did.unwrap(), "did:plc:alice"); 28 - let acc = loaded.accounts.get("did:plc:alice").unwrap(); 29 - assert_eq!(acc.pds_url, "https://pds.test"); 30 - assert_eq!(acc.handle, "alice.test"); 31 - }); 27 + let loaded = storage.load_config_anyhow().unwrap(); 28 + assert_eq!(loaded.default_did.unwrap(), "did:plc:alice"); 29 + let acc = loaded.accounts.get("did:plc:alice").unwrap(); 30 + assert_eq!(acc.pds_url, "https://pds.test"); 31 + assert_eq!(acc.handle, "alice.test"); 32 32 } 33 33 34 34 #[test] 35 35 fn config_with_multiple_accounts_roundtrips() { 36 - with_test_dir(|_| { 37 - let mut accounts = BTreeMap::new(); 38 - accounts.insert( 39 - "did:plc:alice".into(), 40 - AccountConfig { 41 - pds_url: "https://pds.alice".into(), 42 - handle: "alice.test".into(), 43 - }, 44 - ); 45 - accounts.insert( 46 - "did:plc:bob".into(), 47 - AccountConfig { 48 - pds_url: "https://pds.bob".into(), 49 - handle: "bob.test".into(), 50 - }, 51 - ); 52 - let config = Config { 53 - default_did: Some("did:plc:alice".into()), 54 - accounts, 55 - appview_url: None, 56 - }; 57 - save_config(&config).unwrap(); 36 + let (_dir, storage) = test_storage(); 37 + let mut accounts = BTreeMap::new(); 38 + accounts.insert( 39 + "did:plc:alice".into(), 40 + AccountConfig { 41 + pds_url: "https://pds.alice".into(), 42 + handle: "alice.test".into(), 43 + }, 44 + ); 45 + accounts.insert( 46 + "did:plc:bob".into(), 47 + AccountConfig { 48 + pds_url: "https://pds.bob".into(), 49 + handle: "bob.test".into(), 50 + }, 51 + ); 52 + let config = Config { 53 + default_did: Some("did:plc:alice".into()), 54 + accounts, 55 + appview_url: None, 56 + }; 57 + storage.save_config_anyhow(&config).unwrap(); 58 58 59 - let loaded = load_config().unwrap(); 60 - assert_eq!(loaded.accounts.len(), 2); 61 - assert_eq!( 62 - loaded.accounts.get("did:plc:bob").unwrap().handle, 63 - "bob.test" 64 - ); 65 - }); 59 + let loaded = storage.load_config_anyhow().unwrap(); 60 + assert_eq!(loaded.accounts.len(), 2); 61 + assert_eq!( 62 + loaded.accounts.get("did:plc:bob").unwrap().handle, 63 + "bob.test" 64 + ); 66 65 } 67 66 68 67 #[test] ··· 77 76 78 77 #[test] 79 78 fn account_dir_uses_sanitized_did() { 80 - with_test_dir(|_| { 81 - let dir = account_dir("did:plc:test"); 82 - assert!(dir.ends_with("accounts/did_plc_test")); 83 - }); 79 + let (_dir, storage) = test_storage(); 80 + let dir = storage.account_dir("did:plc:test"); 81 + assert!(dir.ends_with("accounts/did_plc_test")); 84 82 } 85 83 86 84 #[test] 87 85 fn save_and_load_account_json_roundtrip() { 88 - with_test_dir(|_| { 89 - let did = "did:plc:test"; 90 - let data = serde_json::json!({"key": "value"}); 91 - save_account_json(did, "test.json", &data).unwrap(); 86 + let (_dir, storage) = test_storage(); 87 + let did = "did:plc:test"; 88 + let data = serde_json::json!({"key": "value"}); 89 + storage.save_account_json(did, "test.json", &data).unwrap(); 92 90 93 - let loaded: serde_json::Value = load_account_json(did, "test.json").unwrap(); 94 - assert_eq!(loaded["key"], "value"); 95 - }); 91 + let loaded: serde_json::Value = storage.load_account_json(did, "test.json").unwrap(); 92 + assert_eq!(loaded["key"], "value"); 96 93 } 97 94 98 95 #[test] 99 96 fn load_account_json_missing_file_errors() { 100 - with_test_dir(|_| { 101 - let result: anyhow::Result<serde_json::Value> = 102 - load_account_json("did:plc:nobody", "nope.json"); 103 - let err = result.unwrap_err().to_string(); 104 - assert!(err.contains("opake login"), "expected login hint: {err}"); 105 - }); 97 + let (_dir, storage) = test_storage(); 98 + let result: anyhow::Result<serde_json::Value> = 99 + storage.load_account_json("did:plc:nobody", "nope.json"); 100 + let err = result.unwrap_err().to_string(); 101 + assert!(err.contains("opake login"), "expected login hint: {err}"); 106 102 } 107 103 108 104 #[test] 109 105 fn ensure_account_dir_creates_nested_dirs() { 110 - with_test_dir(|_| { 111 - let did = "did:plc:nested"; 112 - ensure_account_dir(did).unwrap(); 113 - assert!(account_dir(did).exists()); 114 - }); 106 + let (_dir, storage) = test_storage(); 107 + let did = "did:plc:nested"; 108 + storage.ensure_account_dir(did).unwrap(); 109 + assert!(storage.account_dir(did).exists()); 115 110 } 116 111 117 112 #[test] 118 113 fn load_config_without_file_errors() { 119 - with_test_dir(|_| { 120 - let result = load_config(); 121 - assert!(result.is_err()); 122 - let err = result.unwrap_err().to_string(); 123 - assert!(err.contains("opake login"), "expected login hint: {err}"); 124 - }); 114 + let (_dir, storage) = test_storage(); 115 + let result = storage.load_config_anyhow(); 116 + assert!(result.is_err()); 117 + let err = result.unwrap_err().to_string(); 118 + assert!(err.contains("opake login"), "expected login hint: {err}"); 125 119 } 126 120 127 121 #[test] 128 - fn ensure_data_dir_creates_directory() { 129 - with_test_dir(|dir| { 130 - let target = dir.path().join("nested"); 131 - init_data_dir(Some(target.clone())); 132 - assert!(!target.exists()); 133 - ensure_data_dir().unwrap(); 134 - assert!(target.exists()); 135 - }); 122 + fn ensure_base_dir_creates_directory() { 123 + let (dir, _) = test_storage(); 124 + let target = dir.path().join("nested"); 125 + let storage = FileStorage::new(target.clone()); 126 + assert!(!target.exists()); 127 + storage.ensure_base_dir().unwrap(); 128 + assert!(target.exists()); 136 129 } 137 130 138 131 #[test] 139 132 fn load_config_rejects_garbage_content() { 140 - with_test_dir(|_| { 141 - ensure_data_dir().unwrap(); 142 - fs::write(data_dir().join("config.toml"), "not valid toml {{{").unwrap(); 143 - let result = load_config(); 144 - assert!(result.is_err()); 145 - }); 133 + let (_dir, storage) = test_storage(); 134 + storage.ensure_base_dir().unwrap(); 135 + fs::write(storage.base_dir().join("config.toml"), "not valid toml {{{").unwrap(); 136 + let result = storage.load_config_anyhow(); 137 + assert!(result.is_err()); 146 138 } 147 139 148 140 #[test] 149 141 fn load_config_ignores_unknown_keys() { 150 - with_test_dir(|_| { 151 - ensure_data_dir().unwrap(); 152 - fs::write(data_dir().join("config.toml"), "[section]\nkey = 42\n").unwrap(); 153 - // New Config has all optional/default fields — unknown keys are ignored 154 - let loaded = load_config().unwrap(); 155 - assert!(loaded.default_did.is_none()); 156 - assert!(loaded.accounts.is_empty()); 157 - }); 142 + let (_dir, storage) = test_storage(); 143 + storage.ensure_base_dir().unwrap(); 144 + fs::write( 145 + storage.base_dir().join("config.toml"), 146 + "[section]\nkey = 42\n", 147 + ) 148 + .unwrap(); 149 + // New Config has all optional/default fields — unknown keys are ignored 150 + let loaded = storage.load_config_anyhow().unwrap(); 151 + assert!(loaded.default_did.is_none()); 152 + assert!(loaded.accounts.is_empty()); 158 153 } 159 154 160 155 #[test] 161 156 fn load_config_empty_file_gives_defaults() { 162 - with_test_dir(|_| { 163 - ensure_data_dir().unwrap(); 164 - fs::write(data_dir().join("config.toml"), "").unwrap(); 165 - let loaded = load_config().unwrap(); 166 - assert!(loaded.default_did.is_none()); 167 - assert!(loaded.accounts.is_empty()); 168 - }); 157 + let (_dir, storage) = test_storage(); 158 + storage.ensure_base_dir().unwrap(); 159 + fs::write(storage.base_dir().join("config.toml"), "").unwrap(); 160 + let loaded = storage.load_config_anyhow().unwrap(); 161 + assert!(loaded.default_did.is_none()); 162 + assert!(loaded.accounts.is_empty()); 169 163 } 170 164 171 165 #[test] 172 166 fn load_config_rejects_binary_noise() { 173 - with_test_dir(|_| { 174 - ensure_data_dir().unwrap(); 175 - fs::write(data_dir().join("config.toml"), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 176 - let result = load_config(); 177 - assert!(result.is_err()); 178 - }); 167 + let (_dir, storage) = test_storage(); 168 + storage.ensure_base_dir().unwrap(); 169 + fs::write( 170 + storage.base_dir().join("config.toml"), 171 + vec![0xFF, 0xFE, 0x00, 0x01], 172 + ) 173 + .unwrap(); 174 + let result = storage.load_config_anyhow(); 175 + assert!(result.is_err()); 179 176 } 180 177 181 178 // -- resolve_handle_or_did -- ··· 205 202 206 203 #[test] 207 204 fn remove_account_deletes_dir_and_config_entry() { 208 - with_test_dir(|_| { 209 - let did = "did:plc:alice"; 210 - let config = test_config(did, "https://pds.alice", "alice.test"); 211 - save_config(&config).unwrap(); 212 - ensure_account_dir(did).unwrap(); 213 - assert!(account_dir(did).exists()); 205 + let (_dir, storage) = test_storage(); 206 + let did = "did:plc:alice"; 207 + let config = test_config(did, "https://pds.alice", "alice.test"); 208 + storage.save_config_anyhow(&config).unwrap(); 209 + storage.ensure_account_dir(did).unwrap(); 210 + assert!(storage.account_dir(did).exists()); 214 211 215 - remove_account(did).unwrap(); 212 + storage.remove_account(did).unwrap(); 216 213 217 - let loaded = load_config().unwrap(); 218 - assert!(!loaded.accounts.contains_key(did)); 219 - assert!(loaded.default_did.is_none()); 220 - assert!(!account_dir(did).exists()); 221 - }); 214 + let loaded = storage.load_config_anyhow().unwrap(); 215 + assert!(!loaded.accounts.contains_key(did)); 216 + assert!(loaded.default_did.is_none()); 217 + assert!(!storage.account_dir(did).exists()); 222 218 } 223 219 224 220 #[test] 225 221 fn remove_account_promotes_next_default() { 226 - with_test_dir(|_| { 227 - let mut accounts = BTreeMap::new(); 228 - accounts.insert( 229 - "did:plc:alice".into(), 230 - AccountConfig { 231 - pds_url: "https://pds.alice".into(), 232 - handle: "alice.test".into(), 233 - }, 234 - ); 235 - accounts.insert( 236 - "did:plc:bob".into(), 237 - AccountConfig { 238 - pds_url: "https://pds.bob".into(), 239 - handle: "bob.test".into(), 240 - }, 241 - ); 242 - save_config(&Config { 222 + let (_dir, storage) = test_storage(); 223 + let mut accounts = BTreeMap::new(); 224 + accounts.insert( 225 + "did:plc:alice".into(), 226 + AccountConfig { 227 + pds_url: "https://pds.alice".into(), 228 + handle: "alice.test".into(), 229 + }, 230 + ); 231 + accounts.insert( 232 + "did:plc:bob".into(), 233 + AccountConfig { 234 + pds_url: "https://pds.bob".into(), 235 + handle: "bob.test".into(), 236 + }, 237 + ); 238 + storage 239 + .save_config_anyhow(&Config { 243 240 default_did: Some("did:plc:alice".into()), 244 241 accounts, 245 242 appview_url: None, 246 243 }) 247 244 .unwrap(); 248 245 249 - remove_account("did:plc:alice").unwrap(); 246 + storage.remove_account("did:plc:alice").unwrap(); 250 247 251 - let loaded = load_config().unwrap(); 252 - assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 253 - assert_eq!(loaded.accounts.len(), 1); 254 - }); 248 + let loaded = storage.load_config_anyhow().unwrap(); 249 + assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 250 + assert_eq!(loaded.accounts.len(), 1); 255 251 } 256 252 257 253 #[test] 258 254 fn remove_account_unknown_did_errors() { 259 - with_test_dir(|_| { 260 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 261 - save_config(&config).unwrap(); 255 + let (_dir, storage) = test_storage(); 256 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 257 + storage.save_config_anyhow(&config).unwrap(); 262 258 263 - let err = remove_account("did:plc:nobody").unwrap_err(); 264 - assert!(err.to_string().contains("did:plc:nobody")); 265 - }); 259 + let err = storage.remove_account("did:plc:nobody").unwrap_err(); 260 + assert!(err.to_string().contains("did:plc:nobody")); 266 261 } 267 262 268 263 #[test] 269 264 fn remove_account_without_dir_still_works() { 270 - with_test_dir(|_| { 271 - let did = "did:plc:alice"; 272 - let config = test_config(did, "https://pds.test", "alice.test"); 273 - save_config(&config).unwrap(); 274 - // don't create account dir — should still succeed 265 + let (_dir, storage) = test_storage(); 266 + let did = "did:plc:alice"; 267 + let config = test_config(did, "https://pds.test", "alice.test"); 268 + storage.save_config_anyhow(&config).unwrap(); 269 + // don't create account dir — should still succeed 275 270 276 - remove_account(did).unwrap(); 271 + storage.remove_account(did).unwrap(); 277 272 278 - let loaded = load_config().unwrap(); 279 - assert!(!loaded.accounts.contains_key(did)); 280 - }); 273 + let loaded = storage.load_config_anyhow().unwrap(); 274 + assert!(!loaded.accounts.contains_key(did)); 281 275 } 282 276 283 277 // -- resolve_appview_url -- 284 278 285 279 #[test] 286 280 fn resolve_appview_url_explicit_flag_wins() { 287 - with_test_dir(|_| { 288 - std::env::set_var("OPAKE_APPVIEW_URL", "https://env.test"); 289 - let result = resolve_appview_url(Some("https://flag.test")).unwrap(); 290 - assert_eq!(result, "https://flag.test"); 291 - std::env::remove_var("OPAKE_APPVIEW_URL"); 292 - }); 281 + let (_dir, storage) = test_storage(); 282 + std::env::set_var("OPAKE_APPVIEW_URL", "https://env.test"); 283 + let result = storage 284 + .resolve_appview_url(Some("https://flag.test")) 285 + .unwrap(); 286 + assert_eq!(result, "https://flag.test"); 287 + std::env::remove_var("OPAKE_APPVIEW_URL"); 293 288 } 294 289 295 290 #[test] 296 291 fn resolve_appview_url_env_over_config() { 297 - with_test_dir(|_| { 298 - let mut config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 299 - config.appview_url = Some("https://config.test".into()); 300 - save_config(&config).unwrap(); 292 + let (_dir, storage) = test_storage(); 293 + let mut config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 294 + config.appview_url = Some("https://config.test".into()); 295 + storage.save_config_anyhow(&config).unwrap(); 301 296 302 - std::env::set_var("OPAKE_APPVIEW_URL", "https://env.test"); 303 - let result = resolve_appview_url(None).unwrap(); 304 - assert_eq!(result, "https://env.test"); 305 - std::env::remove_var("OPAKE_APPVIEW_URL"); 306 - }); 297 + std::env::set_var("OPAKE_APPVIEW_URL", "https://env.test"); 298 + let result = storage.resolve_appview_url(None).unwrap(); 299 + assert_eq!(result, "https://env.test"); 300 + std::env::remove_var("OPAKE_APPVIEW_URL"); 307 301 } 308 302 309 303 #[test] 310 304 fn resolve_appview_url_config_fallback() { 311 - with_test_dir(|_| { 312 - std::env::remove_var("OPAKE_APPVIEW_URL"); 313 - let mut config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 314 - config.appview_url = Some("https://config.test".into()); 315 - save_config(&config).unwrap(); 305 + let (_dir, storage) = test_storage(); 306 + std::env::remove_var("OPAKE_APPVIEW_URL"); 307 + let mut config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 308 + config.appview_url = Some("https://config.test".into()); 309 + storage.save_config_anyhow(&config).unwrap(); 316 310 317 - let result = resolve_appview_url(None).unwrap(); 318 - assert_eq!(result, "https://config.test"); 319 - }); 311 + let result = storage.resolve_appview_url(None).unwrap(); 312 + assert_eq!(result, "https://config.test"); 320 313 } 321 314 322 315 #[test] 323 316 fn resolve_appview_url_missing_gives_clear_error() { 324 - with_test_dir(|_| { 325 - std::env::remove_var("OPAKE_APPVIEW_URL"); 326 - let err = resolve_appview_url(None).unwrap_err().to_string(); 327 - assert!(err.contains("--appview"), "expected usage hint: {err}"); 328 - assert!( 329 - err.contains("OPAKE_APPVIEW_URL"), 330 - "expected env hint: {err}" 331 - ); 332 - }); 317 + let (_dir, storage) = test_storage(); 318 + std::env::remove_var("OPAKE_APPVIEW_URL"); 319 + let err = storage.resolve_appview_url(None).unwrap_err().to_string(); 320 + assert!(err.contains("--appview"), "expected usage hint: {err}"); 321 + assert!( 322 + err.contains("OPAKE_APPVIEW_URL"), 323 + "expected env hint: {err}" 324 + ); 333 325 } 334 326 335 327 // -- permission hardening -- ··· 338 330 339 331 #[test] 340 332 fn write_sensitive_file_sets_0600() { 341 - with_test_dir(|_| { 342 - ensure_data_dir().unwrap(); 343 - let path = data_dir().join("secret.txt"); 344 - write_sensitive_file(&path, "hunter2").unwrap(); 345 - let mode = path.metadata().unwrap().permissions().mode() & 0o777; 346 - assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 347 - }); 333 + let (_dir, storage) = test_storage(); 334 + storage.ensure_base_dir().unwrap(); 335 + let path = storage.base_dir().join("secret.txt"); 336 + FileStorage::write_sensitive_file(&path, "hunter2").unwrap(); 337 + let mode = path.metadata().unwrap().permissions().mode() & 0o777; 338 + assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 348 339 } 349 340 350 341 #[test] 351 342 fn ensure_sensitive_dir_sets_0700() { 352 - with_test_dir(|dir| { 353 - let target = dir.path().join("secure"); 354 - ensure_sensitive_dir(&target).unwrap(); 355 - let mode = target.metadata().unwrap().permissions().mode() & 0o777; 356 - assert_eq!(mode, 0o700, "expected 0700, got {mode:#o}"); 357 - }); 343 + let (dir, _) = test_storage(); 344 + let target = dir.path().join("secure"); 345 + FileStorage::ensure_sensitive_dir(&target).unwrap(); 346 + let mode = target.metadata().unwrap().permissions().mode() & 0o777; 347 + assert_eq!(mode, 0o700, "expected 0700, got {mode:#o}"); 358 348 } 359 349 360 350 #[test] 361 351 fn ensure_sensitive_dir_tightens_existing() { 362 - with_test_dir(|dir| { 363 - let target = dir.path().join("loose"); 364 - fs::create_dir_all(&target).unwrap(); 365 - fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); 352 + let (dir, _) = test_storage(); 353 + let target = dir.path().join("loose"); 354 + fs::create_dir_all(&target).unwrap(); 355 + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); 366 356 367 - ensure_sensitive_dir(&target).unwrap(); 368 - let mode = target.metadata().unwrap().permissions().mode() & 0o777; 369 - assert_eq!(mode, 0o700, "expected 0700, got {mode:#o}"); 370 - }); 357 + FileStorage::ensure_sensitive_dir(&target).unwrap(); 358 + let mode = target.metadata().unwrap().permissions().mode() & 0o777; 359 + assert_eq!(mode, 0o700, "expected 0700, got {mode:#o}"); 371 360 } 372 361 373 362 #[test] 374 363 fn save_config_sets_0600() { 375 - with_test_dir(|_| { 376 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 377 - save_config(&config).unwrap(); 378 - let mode = data_dir() 379 - .join("config.toml") 380 - .metadata() 381 - .unwrap() 382 - .permissions() 383 - .mode() 384 - & 0o777; 385 - assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 386 - }); 364 + let (_dir, storage) = test_storage(); 365 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 366 + storage.save_config_anyhow(&config).unwrap(); 367 + let mode = storage 368 + .base_dir() 369 + .join("config.toml") 370 + .metadata() 371 + .unwrap() 372 + .permissions() 373 + .mode() 374 + & 0o777; 375 + assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 387 376 } 388 377 389 378 #[test] 390 379 fn save_account_json_sets_0600() { 391 - with_test_dir(|_| { 392 - let did = "did:plc:test"; 393 - let data = serde_json::json!({"key": "value"}); 394 - save_account_json(did, "secret.json", &data).unwrap(); 395 - let mode = account_dir(did) 396 - .join("secret.json") 397 - .metadata() 398 - .unwrap() 399 - .permissions() 400 - .mode() 401 - & 0o777; 402 - assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 403 - }); 380 + let (_dir, storage) = test_storage(); 381 + let did = "did:plc:test"; 382 + let data = serde_json::json!({"key": "value"}); 383 + storage 384 + .save_account_json(did, "secret.json", &data) 385 + .unwrap(); 386 + let mode = storage 387 + .account_dir(did) 388 + .join("secret.json") 389 + .metadata() 390 + .unwrap() 391 + .permissions() 392 + .mode() 393 + & 0o777; 394 + assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 404 395 }
+133 -355
crates/opake-cli/src/identity.rs
··· 1 - use std::os::unix::fs::PermissionsExt; 2 - use std::path::Path; 3 - 4 - use anyhow::Context; 5 - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 6 1 use log::info; 7 - use opake_core::crypto::{ 8 - CryptoRng, Ed25519SigningKey, RngCore, X25519DalekPublicKey, X25519DalekStaticSecret, 9 - X25519PrivateKey, X25519PublicKey, 10 - }; 11 - use serde::{Deserialize, Serialize}; 12 - 13 - use crate::config; 14 - 15 - /// Ed25519 signing key: 32 raw bytes (the secret scalar). 16 - pub type Ed25519SecretKey = [u8; 32]; 17 - /// Ed25519 verify key: 32 raw bytes (the public point). 18 - pub type Ed25519VerifyKey = [u8; 32]; 19 - 20 - /// Encryption + signing keypairs, stored as base64 in `identity.json`. 21 - /// The signing fields are optional for backward compat with old identity files. 22 - #[derive(opake_core::RedactedDebug, Serialize, Deserialize)] 23 - pub struct Identity { 24 - pub did: String, 25 - pub public_key: String, 26 - #[redact] 27 - pub private_key: String, 28 - /// Ed25519 signing secret key (base64). 29 - #[serde(default)] 30 - #[redact] 31 - pub signing_key: Option<String>, 32 - /// Ed25519 signing public/verify key (base64). 33 - #[serde(default)] 34 - pub verify_key: Option<String>, 35 - } 36 - 37 - impl Identity { 38 - pub fn public_key_bytes(&self) -> anyhow::Result<X25519PublicKey> { 39 - let bytes = BASE64 40 - .decode(&self.public_key) 41 - .context("invalid base64 in identity public_key")?; 42 - let key: X25519PublicKey = bytes.try_into().map_err(|v: Vec<u8>| { 43 - anyhow::anyhow!("public key is {} bytes, expected 32", v.len()) 44 - })?; 45 - Ok(key) 46 - } 47 - 48 - pub fn private_key_bytes(&self) -> anyhow::Result<X25519PrivateKey> { 49 - let bytes = BASE64 50 - .decode(&self.private_key) 51 - .context("invalid base64 in identity private_key")?; 52 - let key: X25519PrivateKey = bytes.try_into().map_err(|v: Vec<u8>| { 53 - anyhow::anyhow!("private key is {} bytes, expected 32", v.len()) 54 - })?; 55 - Ok(key) 56 - } 57 - 58 - pub fn signing_key_bytes(&self) -> anyhow::Result<Option<Ed25519SecretKey>> { 59 - match &self.signing_key { 60 - None => Ok(None), 61 - Some(b64) => { 62 - let bytes = BASE64 63 - .decode(b64) 64 - .context("invalid base64 in identity signing_key")?; 65 - let key: Ed25519SecretKey = bytes.try_into().map_err(|v: Vec<u8>| { 66 - anyhow::anyhow!("signing key is {} bytes, expected 32", v.len()) 67 - })?; 68 - Ok(Some(key)) 69 - } 70 - } 71 - } 2 + use opake_core::crypto::{CryptoRng, RngCore}; 72 3 73 - pub fn verify_key_bytes(&self) -> anyhow::Result<Option<Ed25519VerifyKey>> { 74 - match &self.verify_key { 75 - None => Ok(None), 76 - Some(b64) => { 77 - let bytes = BASE64 78 - .decode(b64) 79 - .context("invalid base64 in identity verify_key")?; 80 - let key: Ed25519VerifyKey = bytes.try_into().map_err(|v: Vec<u8>| { 81 - anyhow::anyhow!("verify key is {} bytes, expected 32", v.len()) 82 - })?; 83 - Ok(Some(key)) 84 - } 85 - } 86 - } 4 + use crate::config::FileStorage; 87 5 88 - /// Whether this identity has Ed25519 signing keys. 89 - pub fn has_signing_keys(&self) -> bool { 90 - self.signing_key.is_some() && self.verify_key.is_some() 91 - } 92 - } 6 + // Re-export Identity so `crate::identity::Identity` still works in commands. 7 + pub use opake_core::storage::Identity; 93 8 94 - pub fn save_identity(did: &str, identity: &Identity) -> anyhow::Result<()> { 95 - config::save_account_json(did, "identity.json", identity) 9 + pub fn save_identity(storage: &FileStorage, did: &str, identity: &Identity) -> anyhow::Result<()> { 10 + storage.save_account_json(did, "identity.json", identity) 96 11 } 97 12 98 13 /// Bail if identity.json is readable by group or others (like `ssh -o StrictModes`). 99 - fn check_identity_permissions(path: &Path) -> anyhow::Result<()> { 100 - if !path.exists() { 101 - return Ok(()); 102 - } 103 - let mode = path.metadata()?.permissions().mode(); 104 - if mode & 0o077 != 0 { 105 - anyhow::bail!( 106 - "permissions {:#o} for '{}' are too open — private key material must not be \ 107 - accessible by other users. Run: chmod 600 {}", 108 - mode & 0o777, 109 - path.display(), 110 - path.display(), 111 - ); 112 - } 113 - Ok(()) 114 - } 115 - 116 - pub fn load_identity(did: &str) -> anyhow::Result<Identity> { 117 - let path = config::account_dir(did).join("identity.json"); 118 - check_identity_permissions(&path)?; 119 - config::load_account_json(did, "identity.json") 120 - } 121 - 122 - /// Generate a fresh Ed25519 signing keypair, returning (secret_b64, verify_b64). 123 - fn generate_signing_keypair(rng: &mut (impl CryptoRng + RngCore)) -> (String, String) { 124 - let signing_key = Ed25519SigningKey::generate(rng); 125 - let verify_key = signing_key.verifying_key(); 126 - ( 127 - BASE64.encode(signing_key.to_bytes()), 128 - BASE64.encode(verify_key.to_bytes()), 129 - ) 14 + pub fn load_identity(storage: &FileStorage, did: &str) -> anyhow::Result<Identity> { 15 + let path = storage.account_dir(did).join("identity.json"); 16 + FileStorage::check_identity_permissions(&path)?; 17 + storage.load_account_json(did, "identity.json") 130 18 } 131 19 132 20 /// Return the existing identity if present, otherwise generate a new ··· 136 24 /// Migration: old identity files without signing keys get Ed25519 keys 137 25 /// added transparently on load. 138 26 pub fn ensure_identity( 27 + storage: &FileStorage, 139 28 did: &str, 140 29 rng: &mut (impl CryptoRng + RngCore), 141 30 ) -> anyhow::Result<(Identity, bool)> { 142 - if let Ok(mut existing) = load_identity(did) { 31 + if let Ok(mut existing) = load_identity(storage, did) { 143 32 if existing.did == did { 144 - if !existing.has_signing_keys() { 33 + if existing.ensure_signing_keys(rng) { 145 34 info!("migrating identity: adding Ed25519 signing keypair"); 146 - let (sk, vk) = generate_signing_keypair(rng); 147 - existing.signing_key = Some(sk); 148 - existing.verify_key = Some(vk); 149 - save_identity(did, &existing)?; 35 + save_identity(storage, did, &existing)?; 150 36 return Ok((existing, true)); 151 37 } 152 38 return Ok((existing, false)); ··· 157 43 ); 158 44 } 159 45 160 - let private_secret = X25519DalekStaticSecret::random_from_rng(&mut *rng); 161 - let public_key = X25519DalekPublicKey::from(&private_secret); 162 - let (signing_key, verify_key) = generate_signing_keypair(rng); 163 - 164 - let identity = Identity { 165 - did: did.to_string(), 166 - public_key: BASE64.encode(public_key.as_bytes()), 167 - private_key: BASE64.encode(private_secret.to_bytes()), 168 - signing_key: Some(signing_key), 169 - verify_key: Some(verify_key), 170 - }; 171 - save_identity(did, &identity)?; 46 + let identity = Identity::generate(did, rng); 47 + save_identity(storage, did, &identity)?; 172 48 Ok((identity, true)) 173 49 } 174 50 175 51 #[cfg(test)] 176 52 mod tests { 177 53 use super::*; 178 - use crate::utils::test_harness::with_test_dir; 54 + use crate::config::{AccountConfig, Config}; 55 + use crate::utils::test_harness::test_storage; 56 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 179 57 use opake_core::crypto::OsRng; 180 58 use std::collections::BTreeMap; 181 59 use std::os::unix::fs::PermissionsExt; 182 60 183 - fn setup_account(did: &str) { 61 + fn setup_account(storage: &FileStorage, did: &str) { 184 62 let mut accounts = BTreeMap::new(); 185 63 accounts.insert( 186 64 did.to_string(), 187 - config::AccountConfig { 65 + AccountConfig { 188 66 pds_url: "https://pds.test".into(), 189 67 handle: "test.handle".into(), 190 68 }, 191 69 ); 192 - config::save_config(&config::Config { 193 - default_did: Some(did.to_string()), 194 - accounts, 195 - appview_url: None, 196 - }) 197 - .unwrap(); 70 + storage 71 + .save_config_anyhow(&Config { 72 + default_did: Some(did.to_string()), 73 + accounts, 74 + appview_url: None, 75 + }) 76 + .unwrap(); 198 77 } 199 78 200 79 #[test] 201 80 fn save_and_load_identity_roundtrip() { 202 - with_test_dir(|_| { 203 - let did = "did:plc:test"; 204 - setup_account(did); 205 - let identity = Identity { 206 - did: did.into(), 207 - public_key: BASE64.encode([1u8; 32]), 208 - private_key: BASE64.encode([2u8; 32]), 209 - signing_key: Some(BASE64.encode([3u8; 32])), 210 - verify_key: Some(BASE64.encode([4u8; 32])), 211 - }; 212 - save_identity(did, &identity).unwrap(); 81 + let (_dir, storage) = test_storage(); 82 + let did = "did:plc:test"; 83 + setup_account(&storage, did); 84 + let identity = Identity { 85 + did: did.into(), 86 + public_key: BASE64.encode([1u8; 32]), 87 + private_key: BASE64.encode([2u8; 32]), 88 + signing_key: Some(BASE64.encode([3u8; 32])), 89 + verify_key: Some(BASE64.encode([4u8; 32])), 90 + }; 91 + save_identity(&storage, did, &identity).unwrap(); 213 92 214 - let loaded = load_identity(did).unwrap(); 215 - assert_eq!(loaded.did, identity.did); 216 - assert_eq!(loaded.public_key, identity.public_key); 217 - assert_eq!(loaded.private_key, identity.private_key); 218 - assert_eq!(loaded.signing_key, identity.signing_key); 219 - assert_eq!(loaded.verify_key, identity.verify_key); 93 + let loaded = load_identity(&storage, did).unwrap(); 94 + assert_eq!(loaded.did, identity.did); 95 + assert_eq!(loaded.public_key, identity.public_key); 96 + assert_eq!(loaded.private_key, identity.private_key); 97 + assert_eq!(loaded.signing_key, identity.signing_key); 98 + assert_eq!(loaded.verify_key, identity.verify_key); 220 99 221 - assert_eq!(loaded.public_key_bytes().unwrap(), [1u8; 32]); 222 - assert_eq!(loaded.private_key_bytes().unwrap(), [2u8; 32]); 223 - assert_eq!(loaded.signing_key_bytes().unwrap().unwrap(), [3u8; 32]); 224 - assert_eq!(loaded.verify_key_bytes().unwrap().unwrap(), [4u8; 32]); 225 - }); 100 + assert_eq!(loaded.public_key_bytes().unwrap(), [1u8; 32]); 101 + assert_eq!(loaded.private_key_bytes().unwrap(), [2u8; 32]); 102 + assert_eq!(loaded.signing_key_bytes().unwrap().unwrap(), [3u8; 32]); 103 + assert_eq!(loaded.verify_key_bytes().unwrap().unwrap(), [4u8; 32]); 226 104 } 227 105 228 106 #[test] 229 107 fn ensure_identity_generates_when_missing() { 230 - with_test_dir(|_| { 231 - let did = "did:plc:new"; 232 - setup_account(did); 233 - let (identity, generated) = ensure_identity(did, &mut OsRng).unwrap(); 234 - assert!(generated); 235 - assert_eq!(identity.did, did); 236 - assert_eq!(identity.public_key_bytes().unwrap().len(), 32); 237 - assert_eq!(identity.private_key_bytes().unwrap().len(), 32); 238 - assert!(identity.has_signing_keys()); 239 - assert!(identity.signing_key_bytes().unwrap().is_some()); 240 - assert!(identity.verify_key_bytes().unwrap().is_some()); 241 - }); 108 + let (_dir, storage) = test_storage(); 109 + let did = "did:plc:new"; 110 + setup_account(&storage, did); 111 + let (identity, generated) = ensure_identity(&storage, did, &mut OsRng).unwrap(); 112 + assert!(generated); 113 + assert_eq!(identity.did, did); 114 + assert_eq!(identity.public_key_bytes().unwrap().len(), 32); 115 + assert_eq!(identity.private_key_bytes().unwrap().len(), 32); 116 + assert!(identity.has_signing_keys()); 117 + assert!(identity.signing_key_bytes().unwrap().is_some()); 118 + assert!(identity.verify_key_bytes().unwrap().is_some()); 242 119 } 243 120 244 121 #[test] 245 122 fn ensure_identity_returns_existing_when_did_matches() { 246 - with_test_dir(|_| { 247 - let did = "did:plc:same"; 248 - setup_account(did); 249 - let (first, generated) = ensure_identity(did, &mut OsRng).unwrap(); 250 - assert!(generated); 123 + let (_dir, storage) = test_storage(); 124 + let did = "did:plc:same"; 125 + setup_account(&storage, did); 126 + let (first, generated) = ensure_identity(&storage, did, &mut OsRng).unwrap(); 127 + assert!(generated); 251 128 252 - let (second, generated) = ensure_identity(did, &mut OsRng).unwrap(); 253 - assert!(!generated); 254 - assert_eq!(first.public_key, second.public_key); 255 - assert_eq!(first.private_key, second.private_key); 256 - assert_eq!(first.signing_key, second.signing_key); 257 - }); 129 + let (second, generated) = ensure_identity(&storage, did, &mut OsRng).unwrap(); 130 + assert!(!generated); 131 + assert_eq!(first.public_key, second.public_key); 132 + assert_eq!(first.private_key, second.private_key); 133 + assert_eq!(first.signing_key, second.signing_key); 258 134 } 259 135 260 136 #[test] 261 137 fn ensure_identity_migrates_old_identity_without_signing_keys() { 262 - with_test_dir(|_| { 263 - let did = "did:plc:legacy"; 264 - setup_account(did); 138 + let (_dir, storage) = test_storage(); 139 + let did = "did:plc:legacy"; 140 + setup_account(&storage, did); 265 141 266 - // Write an old-format identity (no signing keys) with correct permissions 267 - let old_identity = serde_json::json!({ 268 - "did": did, 269 - "public_key": BASE64.encode([1u8; 32]), 270 - "private_key": BASE64.encode([2u8; 32]), 271 - }); 272 - config::ensure_account_dir(did).unwrap(); 273 - config::write_sensitive_file( 274 - &config::account_dir(did).join("identity.json"), 275 - serde_json::to_string_pretty(&old_identity).unwrap(), 276 - ) 277 - .unwrap(); 142 + // Write an old-format identity (no signing keys) with correct permissions 143 + let old_identity = serde_json::json!({ 144 + "did": did, 145 + "public_key": BASE64.encode([1u8; 32]), 146 + "private_key": BASE64.encode([2u8; 32]), 147 + }); 148 + storage.ensure_account_dir(did).unwrap(); 149 + FileStorage::write_sensitive_file( 150 + &storage.account_dir(did).join("identity.json"), 151 + serde_json::to_string_pretty(&old_identity).unwrap(), 152 + ) 153 + .unwrap(); 278 154 279 - let (identity, generated) = ensure_identity(did, &mut OsRng).unwrap(); 280 - assert!(generated, "migration should report as generated"); 281 - assert!(identity.has_signing_keys()); 282 - // X25519 keys should be preserved 283 - assert_eq!(identity.public_key_bytes().unwrap(), [1u8; 32]); 284 - assert_eq!(identity.private_key_bytes().unwrap(), [2u8; 32]); 155 + let (identity, generated) = ensure_identity(&storage, did, &mut OsRng).unwrap(); 156 + assert!(generated, "migration should report as generated"); 157 + assert!(identity.has_signing_keys()); 158 + // X25519 keys should be preserved 159 + assert_eq!(identity.public_key_bytes().unwrap(), [1u8; 32]); 160 + assert_eq!(identity.private_key_bytes().unwrap(), [2u8; 32]); 285 161 286 - // Re-load should have signing keys persisted 287 - let reloaded = load_identity(did).unwrap(); 288 - assert!(reloaded.has_signing_keys()); 289 - assert_eq!(reloaded.signing_key, identity.signing_key); 290 - }); 162 + // Re-load should have signing keys persisted 163 + let reloaded = load_identity(&storage, did).unwrap(); 164 + assert!(reloaded.has_signing_keys()); 165 + assert_eq!(reloaded.signing_key, identity.signing_key); 291 166 } 292 167 293 168 #[test] 294 169 fn load_identity_rejects_garbage_json() { 295 - with_test_dir(|_| { 296 - let did = "did:plc:test"; 297 - setup_account(did); 298 - config::ensure_account_dir(did).unwrap(); 299 - config::write_sensitive_file( 300 - &config::account_dir(did).join("identity.json"), 301 - "not json {{{", 302 - ) 303 - .unwrap(); 304 - assert!(load_identity(did).is_err()); 305 - }); 170 + let (_dir, storage) = test_storage(); 171 + let did = "did:plc:test"; 172 + setup_account(&storage, did); 173 + storage.ensure_account_dir(did).unwrap(); 174 + FileStorage::write_sensitive_file( 175 + &storage.account_dir(did).join("identity.json"), 176 + "not json {{{", 177 + ) 178 + .unwrap(); 179 + assert!(load_identity(&storage, did).is_err()); 306 180 } 307 181 308 182 #[test] 309 183 fn load_identity_rejects_valid_json_wrong_schema() { 310 - with_test_dir(|_| { 311 - let did = "did:plc:test"; 312 - setup_account(did); 313 - config::ensure_account_dir(did).unwrap(); 314 - config::write_sensitive_file( 315 - &config::account_dir(did).join("identity.json"), 316 - r#"{"color": "blue"}"#, 317 - ) 318 - .unwrap(); 319 - assert!(load_identity(did).is_err()); 320 - }); 321 - } 322 - 323 - #[test] 324 - fn public_key_bytes_rejects_bad_base64() { 325 - let identity = Identity { 326 - did: "did:plc:test".into(), 327 - public_key: "not!valid!base64!!!".into(), 328 - private_key: BASE64.encode([0u8; 32]), 329 - signing_key: None, 330 - verify_key: None, 331 - }; 332 - assert!(identity.public_key_bytes().is_err()); 333 - } 334 - 335 - #[test] 336 - fn private_key_bytes_rejects_bad_base64() { 337 - let identity = Identity { 338 - did: "did:plc:test".into(), 339 - public_key: BASE64.encode([0u8; 32]), 340 - private_key: "~~~garbage~~~".into(), 341 - signing_key: None, 342 - verify_key: None, 343 - }; 344 - assert!(identity.private_key_bytes().is_err()); 345 - } 346 - 347 - #[test] 348 - fn public_key_bytes_rejects_wrong_length() { 349 - let identity = Identity { 350 - did: "did:plc:test".into(), 351 - public_key: BASE64.encode([0u8; 16]), 352 - private_key: BASE64.encode([0u8; 32]), 353 - signing_key: None, 354 - verify_key: None, 355 - }; 356 - let err = identity.public_key_bytes().unwrap_err().to_string(); 357 - assert!(err.contains("16 bytes"), "expected length in error: {err}"); 358 - } 359 - 360 - #[test] 361 - fn private_key_bytes_rejects_wrong_length() { 362 - let identity = Identity { 363 - did: "did:plc:test".into(), 364 - public_key: BASE64.encode([0u8; 32]), 365 - private_key: BASE64.encode([0u8; 64]), 366 - signing_key: None, 367 - verify_key: None, 368 - }; 369 - let err = identity.private_key_bytes().unwrap_err().to_string(); 370 - assert!(err.contains("64 bytes"), "expected length in error: {err}"); 371 - } 372 - 373 - #[test] 374 - fn signing_key_bytes_rejects_bad_base64() { 375 - let identity = Identity { 376 - did: "did:plc:test".into(), 377 - public_key: BASE64.encode([0u8; 32]), 378 - private_key: BASE64.encode([0u8; 32]), 379 - signing_key: Some("!!!bad!!!".into()), 380 - verify_key: None, 381 - }; 382 - assert!(identity.signing_key_bytes().is_err()); 383 - } 384 - 385 - #[test] 386 - fn verify_key_bytes_rejects_wrong_length() { 387 - let identity = Identity { 388 - did: "did:plc:test".into(), 389 - public_key: BASE64.encode([0u8; 32]), 390 - private_key: BASE64.encode([0u8; 32]), 391 - signing_key: None, 392 - verify_key: Some(BASE64.encode([0u8; 16])), 393 - }; 394 - let err = identity.verify_key_bytes().unwrap_err().to_string(); 395 - assert!(err.contains("16 bytes"), "expected length in error: {err}"); 184 + let (_dir, storage) = test_storage(); 185 + let did = "did:plc:test"; 186 + setup_account(&storage, did); 187 + storage.ensure_account_dir(did).unwrap(); 188 + FileStorage::write_sensitive_file( 189 + &storage.account_dir(did).join("identity.json"), 190 + r#"{"color": "blue"}"#, 191 + ) 192 + .unwrap(); 193 + assert!(load_identity(&storage, did).is_err()); 396 194 } 397 195 398 196 #[test] 399 197 fn load_identity_rejects_world_readable() { 400 - with_test_dir(|_| { 401 - let did = "did:plc:test"; 402 - setup_account(did); 403 - let (identity, _) = ensure_identity(did, &mut OsRng).unwrap(); 404 - assert_eq!(identity.did, did); 198 + let (_dir, storage) = test_storage(); 199 + let did = "did:plc:test"; 200 + setup_account(&storage, did); 201 + let (identity, _) = ensure_identity(&storage, did, &mut OsRng).unwrap(); 202 + assert_eq!(identity.did, did); 405 203 406 - // Loosen permissions to simulate a bad umask 407 - let path = config::account_dir(did).join("identity.json"); 408 - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); 204 + // Loosen permissions to simulate a bad umask 205 + let path = storage.account_dir(did).join("identity.json"); 206 + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); 409 207 410 - let err = load_identity(did).unwrap_err().to_string(); 411 - assert!(err.contains("too open"), "expected 'too open': {err}"); 412 - assert!(err.contains("chmod 600"), "expected chmod hint: {err}"); 413 - }); 208 + let err = load_identity(&storage, did).unwrap_err().to_string(); 209 + assert!(err.contains("too open"), "expected 'too open': {err}"); 210 + assert!(err.contains("chmod 600"), "expected chmod hint: {err}"); 414 211 } 415 212 416 213 #[test] 417 214 fn load_identity_accepts_0600() { 418 - with_test_dir(|_| { 419 - let did = "did:plc:test"; 420 - setup_account(did); 421 - let (_, _) = ensure_identity(did, &mut OsRng).unwrap(); 422 - 423 - // save_identity goes through write_sensitive_file, so already 0600 424 - let loaded = load_identity(did).unwrap(); 425 - assert_eq!(loaded.did, did); 426 - }); 427 - } 428 - 429 - #[test] 430 - fn has_signing_keys_requires_both() { 431 - let mut identity = Identity { 432 - did: "did:plc:test".into(), 433 - public_key: BASE64.encode([0u8; 32]), 434 - private_key: BASE64.encode([0u8; 32]), 435 - signing_key: None, 436 - verify_key: None, 437 - }; 438 - assert!(!identity.has_signing_keys()); 215 + let (_dir, storage) = test_storage(); 216 + let did = "did:plc:test"; 217 + setup_account(&storage, did); 218 + let (_, _) = ensure_identity(&storage, did, &mut OsRng).unwrap(); 439 219 440 - identity.signing_key = Some(BASE64.encode([0u8; 32])); 441 - assert!(!identity.has_signing_keys()); 442 - 443 - identity.verify_key = Some(BASE64.encode([0u8; 32])); 444 - assert!(identity.has_signing_keys()); 220 + // save_identity goes through write_sensitive_file, so already 0600 221 + let loaded = load_identity(&storage, did).unwrap(); 222 + assert_eq!(loaded.did, did); 445 223 } 446 224 }
+153 -139
crates/opake-cli/src/keyring_store.rs
··· 7 7 // The file stores an array of (rotation, group_key) pairs so that keys from 8 8 // previous rotations remain available for decrypting older documents. 9 9 // 10 - // Storage path: ~/.config/opake/accounts/<did>/keyrings/<rkey>.json 10 + // Storage path: <base_dir>/accounts/<did>/keyrings/<rkey>.json 11 11 12 12 use anyhow::Context; 13 13 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 14 14 use opake_core::crypto::ContentKey; 15 15 use serde::{Deserialize, Serialize}; 16 16 17 - use crate::config; 17 + use crate::config::FileStorage; 18 18 19 19 #[derive(Serialize, Deserialize)] 20 20 struct RotationEntry { ··· 41 41 Legacy(LegacyStoredGroupKey), 42 42 } 43 43 44 - fn keyrings_dir(did: &str) -> std::path::PathBuf { 45 - config::account_dir(did).join("keyrings") 44 + fn keyrings_dir(storage: &FileStorage, did: &str) -> std::path::PathBuf { 45 + storage.account_dir(did).join("keyrings") 46 46 } 47 47 48 - fn key_path(did: &str, rkey: &str) -> std::path::PathBuf { 49 - keyrings_dir(did).join(format!("{rkey}.json")) 48 + fn key_path(storage: &FileStorage, did: &str, rkey: &str) -> std::path::PathBuf { 49 + keyrings_dir(storage, did).join(format!("{rkey}.json")) 50 50 } 51 51 52 - fn load_stored(did: &str, rkey: &str) -> anyhow::Result<StoredKeys> { 53 - let path = key_path(did, rkey); 52 + fn load_stored(storage: &FileStorage, did: &str, rkey: &str) -> anyhow::Result<StoredKeys> { 53 + let path = key_path(storage, did, rkey); 54 54 let content = std::fs::read_to_string(&path).with_context(|| { 55 55 format!( 56 56 "no local group key for keyring {rkey} — you may not be a member, or the key was lost" ··· 71 71 } 72 72 } 73 73 74 - fn save_stored(did: &str, rkey: &str, stored: &StoredKeys) -> anyhow::Result<()> { 75 - config::ensure_sensitive_dir(&keyrings_dir(did))?; 74 + fn save_stored( 75 + storage: &FileStorage, 76 + did: &str, 77 + rkey: &str, 78 + stored: &StoredKeys, 79 + ) -> anyhow::Result<()> { 80 + FileStorage::ensure_sensitive_dir(&keyrings_dir(storage, did))?; 76 81 77 82 let json = serde_json::to_string_pretty(stored).context("failed to serialize group key")?; 78 - config::write_sensitive_file(&key_path(did, rkey), &json).with_context(|| { 83 + FileStorage::write_sensitive_file(&key_path(storage, did, rkey), &json).with_context(|| { 79 84 format!( 80 85 "failed to write group key: {}", 81 - key_path(did, rkey).display() 86 + key_path(storage, did, rkey).display() 82 87 ) 83 88 }) 84 89 } 85 90 86 91 pub fn save_group_key( 92 + storage: &FileStorage, 87 93 did: &str, 88 94 rkey: &str, 89 95 rotation: u64, 90 96 group_key: &ContentKey, 91 97 ) -> anyhow::Result<()> { 92 98 // Load existing entries (or start fresh) and upsert 93 - let mut stored = load_stored(did, rkey).unwrap_or(StoredKeys { keys: Vec::new() }); 99 + let mut stored = load_stored(storage, did, rkey).unwrap_or(StoredKeys { keys: Vec::new() }); 94 100 95 101 let encoded = BASE64.encode(group_key.0); 96 102 if let Some(entry) = stored.keys.iter_mut().find(|e| e.rotation == rotation) { ··· 102 108 }); 103 109 } 104 110 105 - save_stored(did, rkey, &stored) 111 + save_stored(storage, did, rkey, &stored) 106 112 } 107 113 108 - pub fn load_group_key(did: &str, rkey: &str, rotation: u64) -> anyhow::Result<ContentKey> { 109 - let stored = load_stored(did, rkey)?; 114 + pub fn load_group_key( 115 + storage: &FileStorage, 116 + did: &str, 117 + rkey: &str, 118 + rotation: u64, 119 + ) -> anyhow::Result<ContentKey> { 120 + let stored = load_stored(storage, did, rkey)?; 110 121 111 122 let entry = stored 112 123 .keys ··· 133 144 #[cfg(test)] 134 145 mod tests { 135 146 use super::*; 136 - use crate::config; 137 - use crate::utils::test_harness::with_test_dir; 147 + use crate::config::{AccountConfig, Config}; 148 + use crate::utils::test_harness::test_storage; 138 149 use opake_core::crypto::{generate_content_key, OsRng}; 139 150 use std::collections::BTreeMap; 140 151 use std::os::unix::fs::PermissionsExt; 141 152 142 - fn setup_account(did: &str) { 153 + fn setup_account(storage: &FileStorage, did: &str) { 143 154 let mut accounts = BTreeMap::new(); 144 155 accounts.insert( 145 156 did.to_string(), 146 - config::AccountConfig { 157 + AccountConfig { 147 158 pds_url: "https://pds.test".into(), 148 159 handle: "test.handle".into(), 149 160 }, 150 161 ); 151 - config::save_config(&config::Config { 152 - default_did: Some(did.to_string()), 153 - accounts, 154 - appview_url: None, 155 - }) 156 - .unwrap(); 162 + storage 163 + .save_config_anyhow(&Config { 164 + default_did: Some(did.to_string()), 165 + accounts, 166 + appview_url: None, 167 + }) 168 + .unwrap(); 157 169 } 158 170 159 171 #[test] 160 172 fn save_and_load_roundtrips() { 161 - with_test_dir(|_| { 162 - let did = "did:plc:test"; 163 - setup_account(did); 164 - let group_key = generate_content_key(&mut OsRng); 165 - save_group_key(did, "tid123", 0, &group_key).unwrap(); 173 + let (_dir, storage) = test_storage(); 174 + let did = "did:plc:test"; 175 + setup_account(&storage, did); 176 + let group_key = generate_content_key(&mut OsRng); 177 + save_group_key(&storage, did, "tid123", 0, &group_key).unwrap(); 166 178 167 - let loaded = load_group_key(did, "tid123", 0).unwrap(); 168 - assert_eq!(loaded.0, group_key.0); 169 - }); 179 + let loaded = load_group_key(&storage, did, "tid123", 0).unwrap(); 180 + assert_eq!(loaded.0, group_key.0); 170 181 } 171 182 172 183 #[test] 173 184 fn multiple_rotations_stored() { 174 - with_test_dir(|_| { 175 - let did = "did:plc:test"; 176 - setup_account(did); 177 - let key0 = generate_content_key(&mut OsRng); 178 - let key1 = generate_content_key(&mut OsRng); 179 - let key2 = generate_content_key(&mut OsRng); 185 + let (_dir, storage) = test_storage(); 186 + let did = "did:plc:test"; 187 + setup_account(&storage, did); 188 + let key0 = generate_content_key(&mut OsRng); 189 + let key1 = generate_content_key(&mut OsRng); 190 + let key2 = generate_content_key(&mut OsRng); 180 191 181 - save_group_key(did, "tid1", 0, &key0).unwrap(); 182 - save_group_key(did, "tid1", 1, &key1).unwrap(); 183 - save_group_key(did, "tid1", 2, &key2).unwrap(); 192 + save_group_key(&storage, did, "tid1", 0, &key0).unwrap(); 193 + save_group_key(&storage, did, "tid1", 1, &key1).unwrap(); 194 + save_group_key(&storage, did, "tid1", 2, &key2).unwrap(); 184 195 185 - assert_eq!(load_group_key(did, "tid1", 0).unwrap().0, key0.0); 186 - assert_eq!(load_group_key(did, "tid1", 1).unwrap().0, key1.0); 187 - assert_eq!(load_group_key(did, "tid1", 2).unwrap().0, key2.0); 188 - }); 196 + assert_eq!(load_group_key(&storage, did, "tid1", 0).unwrap().0, key0.0); 197 + assert_eq!(load_group_key(&storage, did, "tid1", 1).unwrap().0, key1.0); 198 + assert_eq!(load_group_key(&storage, did, "tid1", 2).unwrap().0, key2.0); 189 199 } 190 200 191 201 #[test] 192 202 fn upsert_does_not_clobber_other_rotations() { 193 - with_test_dir(|_| { 194 - let did = "did:plc:test"; 195 - setup_account(did); 196 - let key0 = generate_content_key(&mut OsRng); 197 - let key1_v1 = generate_content_key(&mut OsRng); 198 - let key1_v2 = generate_content_key(&mut OsRng); 203 + let (_dir, storage) = test_storage(); 204 + let did = "did:plc:test"; 205 + setup_account(&storage, did); 206 + let key0 = generate_content_key(&mut OsRng); 207 + let key1_v1 = generate_content_key(&mut OsRng); 208 + let key1_v2 = generate_content_key(&mut OsRng); 199 209 200 - save_group_key(did, "tid1", 0, &key0).unwrap(); 201 - save_group_key(did, "tid1", 1, &key1_v1).unwrap(); 202 - // Overwrite rotation 1 — rotation 0 must survive 203 - save_group_key(did, "tid1", 1, &key1_v2).unwrap(); 210 + save_group_key(&storage, did, "tid1", 0, &key0).unwrap(); 211 + save_group_key(&storage, did, "tid1", 1, &key1_v1).unwrap(); 212 + // Overwrite rotation 1 — rotation 0 must survive 213 + save_group_key(&storage, did, "tid1", 1, &key1_v2).unwrap(); 204 214 205 - assert_eq!(load_group_key(did, "tid1", 0).unwrap().0, key0.0); 206 - assert_eq!(load_group_key(did, "tid1", 1).unwrap().0, key1_v2.0); 207 - }); 215 + assert_eq!(load_group_key(&storage, did, "tid1", 0).unwrap().0, key0.0); 216 + assert_eq!( 217 + load_group_key(&storage, did, "tid1", 1).unwrap().0, 218 + key1_v2.0 219 + ); 208 220 } 209 221 210 222 #[test] 211 223 fn load_missing_key_errors() { 212 - with_test_dir(|_| { 213 - let did = "did:plc:test"; 214 - setup_account(did); 215 - let err = load_group_key(did, "nonexistent", 0).unwrap_err(); 216 - assert!(err.to_string().contains("no local group key"), "got: {err}"); 217 - }); 224 + let (_dir, storage) = test_storage(); 225 + let did = "did:plc:test"; 226 + setup_account(&storage, did); 227 + let err = load_group_key(&storage, did, "nonexistent", 0).unwrap_err(); 228 + assert!(err.to_string().contains("no local group key"), "got: {err}"); 218 229 } 219 230 220 231 #[test] 221 232 fn load_missing_rotation_errors() { 222 - with_test_dir(|_| { 223 - let did = "did:plc:test"; 224 - setup_account(did); 225 - let key = generate_content_key(&mut OsRng); 226 - save_group_key(did, "tid1", 0, &key).unwrap(); 233 + let (_dir, storage) = test_storage(); 234 + let did = "did:plc:test"; 235 + setup_account(&storage, did); 236 + let key = generate_content_key(&mut OsRng); 237 + save_group_key(&storage, did, "tid1", 0, &key).unwrap(); 227 238 228 - let err = load_group_key(did, "tid1", 99).unwrap_err(); 229 - assert!(err.to_string().contains("rotation 99"), "got: {err}"); 230 - }); 239 + let err = load_group_key(&storage, did, "tid1", 99).unwrap_err(); 240 + assert!(err.to_string().contains("rotation 99"), "got: {err}"); 231 241 } 232 242 233 243 #[test] 234 244 fn load_garbage_json_errors() { 235 - with_test_dir(|_| { 236 - let did = "did:plc:test"; 237 - setup_account(did); 238 - let dir = keyrings_dir(did); 239 - std::fs::create_dir_all(&dir).unwrap(); 240 - std::fs::write(dir.join("bad.json"), "not json {{{").unwrap(); 241 - assert!(load_group_key(did, "bad", 0).is_err()); 242 - }); 245 + let (_dir, storage) = test_storage(); 246 + let did = "did:plc:test"; 247 + setup_account(&storage, did); 248 + let dir = keyrings_dir(&storage, did); 249 + std::fs::create_dir_all(&dir).unwrap(); 250 + std::fs::write(dir.join("bad.json"), "not json {{{").unwrap(); 251 + assert!(load_group_key(&storage, did, "bad", 0).is_err()); 243 252 } 244 253 245 254 #[test] 246 255 fn load_wrong_length_key_errors() { 247 - with_test_dir(|_| { 248 - let did = "did:plc:test"; 249 - setup_account(did); 250 - let dir = keyrings_dir(did); 251 - std::fs::create_dir_all(&dir).unwrap(); 252 - let stored = StoredKeys { 253 - keys: vec![RotationEntry { 254 - rotation: 0, 255 - group_key: BASE64.encode([0u8; 16]), 256 - }], 257 - }; 258 - std::fs::write( 259 - dir.join("short.json"), 260 - serde_json::to_string(&stored).unwrap(), 261 - ) 262 - .unwrap(); 263 - let err = load_group_key(did, "short", 0).unwrap_err(); 264 - assert!(err.to_string().contains("16 bytes"), "got: {err}"); 265 - }); 256 + let (_dir, storage) = test_storage(); 257 + let did = "did:plc:test"; 258 + setup_account(&storage, did); 259 + let dir = keyrings_dir(&storage, did); 260 + std::fs::create_dir_all(&dir).unwrap(); 261 + let stored = StoredKeys { 262 + keys: vec![RotationEntry { 263 + rotation: 0, 264 + group_key: BASE64.encode([0u8; 16]), 265 + }], 266 + }; 267 + std::fs::write( 268 + dir.join("short.json"), 269 + serde_json::to_string(&stored).unwrap(), 270 + ) 271 + .unwrap(); 272 + let err = load_group_key(&storage, did, "short", 0).unwrap_err(); 273 + assert!(err.to_string().contains("16 bytes"), "got: {err}"); 266 274 } 267 275 268 276 #[test] 269 277 fn save_group_key_sets_permissions() { 270 - with_test_dir(|_| { 271 - let did = "did:plc:test"; 272 - setup_account(did); 273 - let group_key = generate_content_key(&mut OsRng); 274 - save_group_key(did, "tid123", 0, &group_key).unwrap(); 278 + let (_dir, storage) = test_storage(); 279 + let did = "did:plc:test"; 280 + setup_account(&storage, did); 281 + let group_key = generate_content_key(&mut OsRng); 282 + save_group_key(&storage, did, "tid123", 0, &group_key).unwrap(); 275 283 276 - let dir_mode = keyrings_dir(did).metadata().unwrap().permissions().mode() & 0o777; 277 - assert_eq!(dir_mode, 0o700, "expected dir 0700, got {dir_mode:#o}"); 284 + let dir_mode = keyrings_dir(&storage, did) 285 + .metadata() 286 + .unwrap() 287 + .permissions() 288 + .mode() 289 + & 0o777; 290 + assert_eq!(dir_mode, 0o700, "expected dir 0700, got {dir_mode:#o}"); 278 291 279 - let file_mode = key_path(did, "tid123") 280 - .metadata() 281 - .unwrap() 282 - .permissions() 283 - .mode() 284 - & 0o777; 285 - assert_eq!(file_mode, 0o600, "expected file 0600, got {file_mode:#o}"); 286 - }); 292 + let file_mode = key_path(&storage, did, "tid123") 293 + .metadata() 294 + .unwrap() 295 + .permissions() 296 + .mode() 297 + & 0o777; 298 + assert_eq!(file_mode, 0o600, "expected file 0600, got {file_mode:#o}"); 287 299 } 288 300 289 301 #[test] 290 302 fn legacy_format_migration() { 291 - with_test_dir(|_| { 292 - let did = "did:plc:test"; 293 - setup_account(did); 294 - let dir = keyrings_dir(did); 295 - std::fs::create_dir_all(&dir).unwrap(); 303 + let (_dir, storage) = test_storage(); 304 + let did = "did:plc:test"; 305 + setup_account(&storage, did); 306 + let dir = keyrings_dir(&storage, did); 307 + std::fs::create_dir_all(&dir).unwrap(); 296 308 297 - // Write old format: { "group_key": "..." } 298 - let key = generate_content_key(&mut OsRng); 299 - let legacy = serde_json::json!({ "group_key": BASE64.encode(key.0) }); 300 - std::fs::write( 301 - dir.join("legacy.json"), 302 - serde_json::to_string(&legacy).unwrap(), 303 - ) 304 - .unwrap(); 309 + // Write old format: { "group_key": "..." } 310 + let key = generate_content_key(&mut OsRng); 311 + let legacy = serde_json::json!({ "group_key": BASE64.encode(key.0) }); 312 + std::fs::write( 313 + dir.join("legacy.json"), 314 + serde_json::to_string(&legacy).unwrap(), 315 + ) 316 + .unwrap(); 305 317 306 - // Should load as rotation 0 307 - let loaded = load_group_key(did, "legacy", 0).unwrap(); 308 - assert_eq!(loaded.0, key.0); 318 + // Should load as rotation 0 319 + let loaded = load_group_key(&storage, did, "legacy", 0).unwrap(); 320 + assert_eq!(loaded.0, key.0); 309 321 310 - // Saving a new rotation upgrades the file format 311 - let key1 = generate_content_key(&mut OsRng); 312 - save_group_key(did, "legacy", 1, &key1).unwrap(); 322 + // Saving a new rotation upgrades the file format 323 + let key1 = generate_content_key(&mut OsRng); 324 + save_group_key(&storage, did, "legacy", 1, &key1).unwrap(); 313 325 314 - // Both rotations accessible 315 - assert_eq!(load_group_key(did, "legacy", 0).unwrap().0, key.0); 316 - assert_eq!(load_group_key(did, "legacy", 1).unwrap().0, key1.0); 317 - }); 326 + // Both rotations accessible 327 + assert_eq!(load_group_key(&storage, did, "legacy", 0).unwrap().0, key.0); 328 + assert_eq!( 329 + load_group_key(&storage, did, "legacy", 1).unwrap().0, 330 + key1.0 331 + ); 318 332 } 319 333 }
+30 -23
crates/opake-cli/src/main.rs
··· 8 8 9 9 use clap::{Parser, Subcommand}; 10 10 use commands::Execute; 11 + use config::FileStorage; 11 12 use log::info; 12 13 13 14 #[derive(Parser)] ··· 51 52 Tree(commands::tree::TreeCommand), 52 53 } 53 54 54 - async fn run_with_context(as_flag: Option<&str>, cmd: impl Execute) -> anyhow::Result<()> { 55 - let ctx = session::resolve_context(as_flag)?; 55 + async fn run_with_context( 56 + storage: &FileStorage, 57 + as_flag: Option<&str>, 58 + cmd: impl Execute, 59 + ) -> anyhow::Result<()> { 60 + let ctx = session::resolve_context(storage, as_flag)?; 56 61 let refreshed = cmd.execute(&ctx).await?; 57 62 if let Some(ref s) = refreshed { 58 - session::persist_session(&s.did, s)?; 63 + session::persist_session(&ctx.storage, &s.did, s)?; 59 64 } 60 65 Ok(()) 61 66 } ··· 69 74 command, 70 75 } = Cli::parse(); 71 76 72 - config::init_data_dir(config_dir.map(Into::into)); 77 + let base_dir = opake_core::paths::resolve_data_dir(config_dir.map(Into::into)); 73 78 74 79 let log_level = match verbose { 75 80 0 => log::LevelFilter::Warn, ··· 84 89 85 90 info!("Starting Opake CLI. Hello!"); 86 91 92 + let storage = FileStorage::new(base_dir); 93 + 87 94 match command { 88 95 Command::Login(cmd) => { 89 - let session = cmd.execute().await?; 96 + let session = cmd.execute(&storage).await?; 90 97 if let Some(ref s) = session { 91 - session::persist_session(&s.did, s)?; 98 + session::persist_session(&storage, &s.did, s)?; 92 99 } 93 100 } 94 - Command::Logout(cmd) => cmd.run()?, 95 - Command::Accounts(cmd) => cmd.run()?, 96 - Command::SetDefault(cmd) => cmd.run()?, 101 + Command::Logout(cmd) => cmd.run(&storage)?, 102 + Command::Accounts(cmd) => cmd.run(&storage)?, 103 + Command::SetDefault(cmd) => cmd.run(&storage)?, 97 104 98 - Command::Upload(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 99 - Command::Download(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 100 - Command::Cat(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 101 - Command::Inbox(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 102 - Command::Ls(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 103 - Command::Mkdir(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 104 - Command::Mv(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 105 - Command::Rm(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 106 - Command::Resolve(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 107 - Command::Share(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 108 - Command::Shared(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 109 - Command::Revoke(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 110 - Command::Keyring(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 111 - Command::Tree(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 105 + Command::Upload(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 106 + Command::Download(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 107 + Command::Cat(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 108 + Command::Inbox(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 109 + Command::Ls(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 110 + Command::Mkdir(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 111 + Command::Mv(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 112 + Command::Rm(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 113 + Command::Resolve(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 114 + Command::Share(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 115 + Command::Shared(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 116 + Command::Revoke(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 117 + Command::Keyring(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 118 + Command::Tree(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 112 119 } 113 120 114 121 Ok(())
+115 -112
crates/opake-cli/src/session.rs
··· 1 1 use log::info; 2 2 use opake_core::client::{Session, XrpcClient}; 3 3 4 - use crate::config; 4 + use crate::config::{resolve_handle_or_did, FileStorage}; 5 5 use crate::transport::ReqwestTransport; 6 6 7 7 /// Resolved account context passed to every command. 8 8 #[derive(Debug)] 9 9 pub struct CommandContext { 10 10 pub did: String, 11 - #[allow(dead_code)] // will be used when load_client takes context directly 12 11 pub pds_url: String, 12 + pub storage: FileStorage, 13 13 } 14 14 15 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()?; 16 + pub fn resolve_context( 17 + storage: &FileStorage, 18 + as_flag: Option<&str>, 19 + ) -> anyhow::Result<CommandContext> { 20 + let config = storage.load_config_anyhow()?; 18 21 19 22 let did = match as_flag { 20 - Some(input) => config::resolve_handle_or_did(&config, input)?, 23 + Some(input) => resolve_handle_or_did(&config, input)?, 21 24 None => config 22 25 .default_did 23 26 .ok_or_else(|| anyhow::anyhow!("no default account: run `opake login` first"))?, ··· 31 34 Ok(CommandContext { 32 35 did, 33 36 pds_url: account.pds_url.clone(), 37 + storage: storage.clone(), 34 38 }) 35 39 } 36 40 37 41 /// Restore a saved session and build an authenticated XRPC client for a specific account. 38 - pub fn load_client(did: &str) -> anyhow::Result<XrpcClient<ReqwestTransport>> { 39 - let config = config::load_config()?; 42 + pub fn load_client( 43 + storage: &FileStorage, 44 + did: &str, 45 + ) -> anyhow::Result<XrpcClient<ReqwestTransport>> { 46 + let config = storage.load_config_anyhow()?; 40 47 let account = config 41 48 .accounts 42 49 .get(did) 43 50 .ok_or_else(|| anyhow::anyhow!("no account for {did}: run `opake login` first"))?; 44 - let session: Session = config::load_account_json(did, "session.json")?; 51 + let session: Session = storage.load_account_json(did, "session.json")?; 45 52 let transport = ReqwestTransport::new(); 46 53 Ok(XrpcClient::with_session( 47 54 transport, ··· 61 68 } 62 69 63 70 /// Persist a refreshed session to disk for a specific account. 64 - pub fn persist_session(did: &str, session: &Session) -> anyhow::Result<()> { 65 - config::save_account_json(did, "session.json", session)?; 71 + pub fn persist_session(storage: &FileStorage, did: &str, session: &Session) -> anyhow::Result<()> { 72 + storage.save_account_json(did, "session.json", session)?; 66 73 info!("persisted refreshed session tokens for {}", did); 67 74 Ok(()) 68 75 } ··· 70 77 #[cfg(test)] 71 78 mod tests { 72 79 use super::*; 73 - use crate::utils::test_harness::with_test_dir; 80 + use crate::config::{AccountConfig, Config}; 81 + use crate::utils::test_harness::test_storage; 74 82 use std::collections::BTreeMap; 75 83 use std::fs; 76 84 ··· 83 91 } 84 92 } 85 93 86 - fn setup_account(did: &str, pds_url: &str, handle: &str) { 94 + fn setup_account(storage: &FileStorage, did: &str, pds_url: &str, handle: &str) { 87 95 let mut accounts = BTreeMap::new(); 88 96 accounts.insert( 89 97 did.to_string(), 90 - config::AccountConfig { 98 + AccountConfig { 91 99 pds_url: pds_url.into(), 92 100 handle: handle.into(), 93 101 }, 94 102 ); 95 - config::save_config(&config::Config { 96 - default_did: Some(did.to_string()), 97 - accounts, 98 - appview_url: None, 99 - }) 100 - .unwrap(); 103 + storage 104 + .save_config_anyhow(&Config { 105 + default_did: Some(did.to_string()), 106 + accounts, 107 + appview_url: None, 108 + }) 109 + .unwrap(); 101 110 } 102 111 103 112 #[test] 104 113 fn persist_and_load_session_roundtrip() { 105 - with_test_dir(|_| { 106 - let did = "did:plc:test123"; 107 - setup_account(did, "https://pds.test", "alice.test"); 108 - let session = fake_session(); 109 - persist_session(did, &session).unwrap(); 114 + let (_dir, storage) = test_storage(); 115 + let did = "did:plc:test123"; 116 + setup_account(&storage, did, "https://pds.test", "alice.test"); 117 + let session = fake_session(); 118 + persist_session(&storage, did, &session).unwrap(); 110 119 111 - let loaded: Session = config::load_account_json(did, "session.json").unwrap(); 112 - assert_eq!(loaded.did, session.did); 113 - assert_eq!(loaded.handle, session.handle); 114 - assert_eq!(loaded.access_jwt, session.access_jwt); 115 - assert_eq!(loaded.refresh_jwt, session.refresh_jwt); 116 - }); 120 + let loaded: Session = storage.load_account_json(did, "session.json").unwrap(); 121 + assert_eq!(loaded.did, session.did); 122 + assert_eq!(loaded.handle, session.handle); 123 + assert_eq!(loaded.access_jwt, session.access_jwt); 124 + assert_eq!(loaded.refresh_jwt, session.refresh_jwt); 117 125 } 118 126 119 127 #[test] 120 128 fn load_client_without_session_errors() { 121 - with_test_dir(|_| { 122 - assert!(load_client("did:plc:nobody").is_err()); 123 - }); 129 + let (_dir, storage) = test_storage(); 130 + assert!(load_client(&storage, "did:plc:nobody").is_err()); 124 131 } 125 132 126 133 #[test] 127 134 fn resolve_context_uses_default_did() { 128 - with_test_dir(|_| { 129 - setup_account("did:plc:alice", "https://pds.alice", "alice.test"); 130 - let ctx = resolve_context(None).unwrap(); 131 - assert_eq!(ctx.did, "did:plc:alice"); 132 - assert_eq!(ctx.pds_url, "https://pds.alice"); 133 - }); 135 + let (_dir, storage) = test_storage(); 136 + setup_account(&storage, "did:plc:alice", "https://pds.alice", "alice.test"); 137 + let ctx = resolve_context(&storage, None).unwrap(); 138 + assert_eq!(ctx.did, "did:plc:alice"); 139 + assert_eq!(ctx.pds_url, "https://pds.alice"); 134 140 } 135 141 136 142 #[test] 137 143 fn resolve_context_with_did_flag() { 138 - with_test_dir(|_| { 139 - let mut accounts = BTreeMap::new(); 140 - accounts.insert( 141 - "did:plc:alice".into(), 142 - config::AccountConfig { 143 - pds_url: "https://pds.alice".into(), 144 - handle: "alice.test".into(), 145 - }, 146 - ); 147 - accounts.insert( 148 - "did:plc:bob".into(), 149 - config::AccountConfig { 150 - pds_url: "https://pds.bob".into(), 151 - handle: "bob.test".into(), 152 - }, 153 - ); 154 - config::save_config(&config::Config { 144 + let (_dir, storage) = test_storage(); 145 + let mut accounts = BTreeMap::new(); 146 + accounts.insert( 147 + "did:plc:alice".into(), 148 + AccountConfig { 149 + pds_url: "https://pds.alice".into(), 150 + handle: "alice.test".into(), 151 + }, 152 + ); 153 + accounts.insert( 154 + "did:plc:bob".into(), 155 + AccountConfig { 156 + pds_url: "https://pds.bob".into(), 157 + handle: "bob.test".into(), 158 + }, 159 + ); 160 + storage 161 + .save_config_anyhow(&Config { 155 162 default_did: Some("did:plc:alice".into()), 156 163 accounts, 157 164 appview_url: None, 158 165 }) 159 166 .unwrap(); 160 167 161 - let ctx = resolve_context(Some("did:plc:bob")).unwrap(); 162 - assert_eq!(ctx.did, "did:plc:bob"); 163 - assert_eq!(ctx.pds_url, "https://pds.bob"); 164 - }); 168 + let ctx = resolve_context(&storage, Some("did:plc:bob")).unwrap(); 169 + assert_eq!(ctx.did, "did:plc:bob"); 170 + assert_eq!(ctx.pds_url, "https://pds.bob"); 165 171 } 166 172 167 173 #[test] 168 174 fn resolve_context_with_handle_flag() { 169 - with_test_dir(|_| { 170 - let mut accounts = BTreeMap::new(); 171 - accounts.insert( 172 - "did:plc:alice".into(), 173 - config::AccountConfig { 174 - pds_url: "https://pds.alice".into(), 175 - handle: "alice.test".into(), 176 - }, 177 - ); 178 - accounts.insert( 179 - "did:plc:bob".into(), 180 - config::AccountConfig { 181 - pds_url: "https://pds.bob".into(), 182 - handle: "bob.test".into(), 183 - }, 184 - ); 185 - config::save_config(&config::Config { 175 + let (_dir, storage) = test_storage(); 176 + let mut accounts = BTreeMap::new(); 177 + accounts.insert( 178 + "did:plc:alice".into(), 179 + AccountConfig { 180 + pds_url: "https://pds.alice".into(), 181 + handle: "alice.test".into(), 182 + }, 183 + ); 184 + accounts.insert( 185 + "did:plc:bob".into(), 186 + AccountConfig { 187 + pds_url: "https://pds.bob".into(), 188 + handle: "bob.test".into(), 189 + }, 190 + ); 191 + storage 192 + .save_config_anyhow(&Config { 186 193 default_did: Some("did:plc:alice".into()), 187 194 accounts, 188 195 appview_url: None, 189 196 }) 190 197 .unwrap(); 191 198 192 - let ctx = resolve_context(Some("bob.test")).unwrap(); 193 - assert_eq!(ctx.did, "did:plc:bob"); 194 - }); 199 + let ctx = resolve_context(&storage, Some("bob.test")).unwrap(); 200 + assert_eq!(ctx.did, "did:plc:bob"); 195 201 } 196 202 197 203 #[test] 198 204 fn resolve_context_unknown_handle_errors() { 199 - with_test_dir(|_| { 200 - setup_account("did:plc:alice", "https://pds.alice", "alice.test"); 201 - let err = resolve_context(Some("nobody.test")).unwrap_err(); 202 - assert!(err.to_string().contains("nobody.test")); 203 - }); 205 + let (_dir, storage) = test_storage(); 206 + setup_account(&storage, "did:plc:alice", "https://pds.alice", "alice.test"); 207 + let err = resolve_context(&storage, Some("nobody.test")).unwrap_err(); 208 + assert!(err.to_string().contains("nobody.test")); 204 209 } 205 210 206 211 #[test] 207 212 fn resolve_context_no_default_errors() { 208 - with_test_dir(|_| { 209 - config::save_config(&config::Config { 213 + let (_dir, storage) = test_storage(); 214 + storage 215 + .save_config_anyhow(&Config { 210 216 default_did: None, 211 217 accounts: BTreeMap::new(), 212 218 appview_url: None, 213 219 }) 214 220 .unwrap(); 215 - let err = resolve_context(None).unwrap_err(); 216 - assert!(err.to_string().contains("opake login")); 217 - }); 221 + let err = resolve_context(&storage, None).unwrap_err(); 222 + assert!(err.to_string().contains("opake login")); 218 223 } 219 224 220 225 #[test] 221 226 fn load_session_rejects_garbage_json() { 222 - with_test_dir(|_| { 223 - let did = "did:plc:test"; 224 - setup_account(did, "https://pds.test", "test.handle"); 225 - config::ensure_account_dir(did).unwrap(); 226 - fs::write( 227 - config::account_dir(did).join("session.json"), 228 - "not json {{{", 229 - ) 230 - .unwrap(); 231 - assert!(load_client(did).is_err()); 232 - }); 227 + let (_dir, storage) = test_storage(); 228 + let did = "did:plc:test"; 229 + setup_account(&storage, did, "https://pds.test", "test.handle"); 230 + storage.ensure_account_dir(did).unwrap(); 231 + fs::write( 232 + storage.account_dir(did).join("session.json"), 233 + "not json {{{", 234 + ) 235 + .unwrap(); 236 + assert!(load_client(&storage, did).is_err()); 233 237 } 234 238 235 239 #[test] 236 240 fn load_session_rejects_valid_json_wrong_schema() { 237 - with_test_dir(|_| { 238 - let did = "did:plc:test"; 239 - setup_account(did, "https://pds.test", "test.handle"); 240 - config::ensure_account_dir(did).unwrap(); 241 - fs::write( 242 - config::account_dir(did).join("session.json"), 243 - r#"{"name": "bob", "age": 42}"#, 244 - ) 245 - .unwrap(); 246 - assert!(load_client(did).is_err()); 247 - }); 241 + let (_dir, storage) = test_storage(); 242 + let did = "did:plc:test"; 243 + setup_account(&storage, did, "https://pds.test", "test.handle"); 244 + storage.ensure_account_dir(did).unwrap(); 245 + fs::write( 246 + storage.account_dir(did).join("session.json"), 247 + r#"{"name": "bob", "age": 42}"#, 248 + ) 249 + .unwrap(); 250 + assert!(load_client(&storage, did).is_err()); 248 251 } 249 252 }
+6 -8
crates/opake-cli/src/utils.rs
··· 9 9 } 10 10 11 11 /// Test helpers for modules that need an isolated data directory. 12 - /// A global mutex prevents parallel tests from stomping each other's state. 13 12 #[cfg(test)] 14 13 pub mod test_harness { 15 - use std::sync::Mutex; 14 + use crate::config::FileStorage; 16 15 use tempfile::TempDir; 17 16 18 - static TEST_LOCK: Mutex<()> = Mutex::new(()); 19 - 20 - pub fn with_test_dir(f: impl FnOnce(&TempDir)) { 21 - let _guard = TEST_LOCK.lock().unwrap(); 17 + /// Create a temporary directory and a FileStorage pointing at it. 18 + /// Each test gets its own instance — no global mutex needed. 19 + pub fn test_storage() -> (TempDir, FileStorage) { 22 20 let dir = TempDir::new().unwrap(); 23 - crate::config::init_data_dir(Some(dir.path().to_path_buf())); 24 - f(&dir); 21 + let storage = FileStorage::new(dir.path().to_path_buf()); 22 + (dir, storage) 25 23 } 26 24 } 27 25
+3
crates/opake-core/src/error.rs
··· 35 35 36 36 #[error("{0}")] 37 37 Serialization(#[from] serde_json::Error), 38 + 39 + #[error("storage error: {0}")] 40 + Storage(String), 38 41 }
+1
crates/opake-core/src/lib.rs
··· 29 29 pub mod records; 30 30 pub mod resolve; 31 31 pub mod sharing; 32 + pub mod storage; 32 33 33 34 #[cfg(any(test, feature = "test-utils"))] 34 35 pub mod test_utils;
+476
crates/opake-core/src/storage.rs
··· 1 + // Storage abstraction for config, identity, and session persistence. 2 + // 3 + // The types live here (opake-core) because they're plain serde structs with no 4 + // platform dependencies. The `Storage` trait defines the contract — CLI 5 + // implements it over the filesystem, the web frontend over IndexedDB. 6 + 7 + use std::collections::BTreeMap; 8 + 9 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 10 + use serde::{Deserialize, Serialize}; 11 + 12 + use crate::client::Session; 13 + use crate::crypto::{ 14 + CryptoRng, Ed25519SigningKey, RngCore, X25519DalekPublicKey, X25519DalekStaticSecret, 15 + X25519PrivateKey, X25519PublicKey, 16 + }; 17 + use crate::error::Error; 18 + 19 + // --------------------------------------------------------------------------- 20 + // Types 21 + // --------------------------------------------------------------------------- 22 + 23 + /// Ed25519 signing key: 32 raw bytes (the secret scalar). 24 + pub type Ed25519SecretKey = [u8; 32]; 25 + /// Ed25519 verify key: 32 raw bytes (the public point). 26 + pub type Ed25519VerifyKey = [u8; 32]; 27 + 28 + /// Persistent CLI configuration — tracks all logged-in accounts. 29 + #[derive(Debug, Default, Serialize, Deserialize)] 30 + pub struct Config { 31 + pub default_did: Option<String>, 32 + #[serde(default)] 33 + pub accounts: BTreeMap<String, AccountConfig>, 34 + #[serde(default)] 35 + pub appview_url: Option<String>, 36 + } 37 + 38 + impl Config { 39 + /// Add an account. Sets it as default if no default exists yet. 40 + pub fn add_account(&mut self, did: String, account: AccountConfig) { 41 + if self.default_did.is_none() { 42 + self.default_did = Some(did.clone()); 43 + } 44 + self.accounts.insert(did, account); 45 + } 46 + 47 + /// Remove an account. Promotes the next account as default if the removed 48 + /// one was the current default (BTreeMap ordering = deterministic). 49 + pub fn remove_account(&mut self, did: &str) -> Result<(), Error> { 50 + if !self.accounts.contains_key(did) { 51 + return Err(Error::Storage(format!("no account for {did}"))); 52 + } 53 + self.accounts.remove(did); 54 + if self.default_did.as_deref() == Some(did) { 55 + self.default_did = self.accounts.keys().next().cloned(); 56 + } 57 + Ok(()) 58 + } 59 + 60 + /// Set the default account. Validates the DID exists in accounts. 61 + pub fn set_default(&mut self, did: &str) -> Result<(), Error> { 62 + let key = self 63 + .accounts 64 + .keys() 65 + .find(|k| k.as_str() == did) 66 + .cloned() 67 + .ok_or_else(|| Error::Storage(format!("no account for {did}")))?; 68 + self.default_did = Some(key); 69 + Ok(()) 70 + } 71 + } 72 + 73 + /// Per-account configuration stored in the global config. 74 + #[derive(Debug, Serialize, Deserialize)] 75 + pub struct AccountConfig { 76 + pub pds_url: String, 77 + pub handle: String, 78 + } 79 + 80 + /// Encryption + signing keypairs, stored as base64. 81 + /// The signing fields are optional for backward compat with old identity files. 82 + #[derive(crate::RedactedDebug, Serialize, Deserialize)] 83 + pub struct Identity { 84 + pub did: String, 85 + pub public_key: String, 86 + #[redact] 87 + pub private_key: String, 88 + /// Ed25519 signing secret key (base64). 89 + #[serde(default)] 90 + #[redact] 91 + pub signing_key: Option<String>, 92 + /// Ed25519 signing public/verify key (base64). 93 + #[serde(default)] 94 + pub verify_key: Option<String>, 95 + } 96 + 97 + impl Identity { 98 + pub fn public_key_bytes(&self) -> Result<X25519PublicKey, Error> { 99 + decode_key_bytes(&self.public_key, "public_key") 100 + } 101 + 102 + pub fn private_key_bytes(&self) -> Result<X25519PrivateKey, Error> { 103 + decode_key_bytes(&self.private_key, "private_key") 104 + } 105 + 106 + pub fn signing_key_bytes(&self) -> Result<Option<Ed25519SecretKey>, Error> { 107 + decode_optional_key_bytes(&self.signing_key, "signing_key") 108 + } 109 + 110 + pub fn verify_key_bytes(&self) -> Result<Option<Ed25519VerifyKey>, Error> { 111 + decode_optional_key_bytes(&self.verify_key, "verify_key") 112 + } 113 + 114 + /// Whether this identity has Ed25519 signing keys. 115 + pub fn has_signing_keys(&self) -> bool { 116 + self.signing_key.is_some() && self.verify_key.is_some() 117 + } 118 + 119 + /// Generate a new identity with random X25519 + Ed25519 keypairs. 120 + pub fn generate(did: &str, rng: &mut (impl CryptoRng + RngCore)) -> Self { 121 + let private_secret = X25519DalekStaticSecret::random_from_rng(&mut *rng); 122 + let public_key = X25519DalekPublicKey::from(&private_secret); 123 + let (signing_key, verify_key) = Self::generate_signing_keypair(rng); 124 + 125 + Identity { 126 + did: did.to_string(), 127 + public_key: BASE64.encode(public_key.as_bytes()), 128 + private_key: BASE64.encode(private_secret.to_bytes()), 129 + signing_key: Some(signing_key), 130 + verify_key: Some(verify_key), 131 + } 132 + } 133 + 134 + /// Add Ed25519 signing keys if missing (migration for old identities). 135 + /// Returns `true` if keys were added, `false` if already present. 136 + pub fn ensure_signing_keys(&mut self, rng: &mut (impl CryptoRng + RngCore)) -> bool { 137 + if self.has_signing_keys() { 138 + return false; 139 + } 140 + let (sk, vk) = Self::generate_signing_keypair(rng); 141 + self.signing_key = Some(sk); 142 + self.verify_key = Some(vk); 143 + true 144 + } 145 + 146 + fn generate_signing_keypair(rng: &mut (impl CryptoRng + RngCore)) -> (String, String) { 147 + let signing_key = Ed25519SigningKey::generate(rng); 148 + let verify_key = signing_key.verifying_key(); 149 + ( 150 + BASE64.encode(signing_key.to_bytes()), 151 + BASE64.encode(verify_key.to_bytes()), 152 + ) 153 + } 154 + } 155 + 156 + fn decode_key_bytes<const N: usize>(b64: &str, field: &str) -> Result<[u8; N], Error> { 157 + let bytes = BASE64 158 + .decode(b64) 159 + .map_err(|e| Error::Storage(format!("invalid base64 in identity {field}: {e}")))?; 160 + bytes 161 + .try_into() 162 + .map_err(|v: Vec<u8>| Error::Storage(format!("{field} is {} bytes, expected {N}", v.len()))) 163 + } 164 + 165 + fn decode_optional_key_bytes<const N: usize>( 166 + value: &Option<String>, 167 + field: &str, 168 + ) -> Result<Option<[u8; N]>, Error> { 169 + match value { 170 + None => Ok(None), 171 + Some(b64) => decode_key_bytes(b64, field).map(Some), 172 + } 173 + } 174 + 175 + // --------------------------------------------------------------------------- 176 + // Pure helpers 177 + // --------------------------------------------------------------------------- 178 + 179 + /// Make a DID safe for use as a directory/key name: `did:plc:abc` → `did_plc_abc`. 180 + pub fn sanitize_did(did: &str) -> String { 181 + did.replace(':', "_") 182 + } 183 + 184 + /// Resolve a handle or DID string to a DID. If the input starts with `did:`, 185 + /// it's returned as-is. Otherwise, it's looked up as a handle in the config. 186 + pub fn resolve_handle_or_did(config: &Config, input: &str) -> Result<String, Error> { 187 + if input.starts_with("did:") { 188 + return Ok(input.to_string()); 189 + } 190 + config 191 + .accounts 192 + .iter() 193 + .find(|(_, acc)| acc.handle == input) 194 + .map(|(did, _)| did.clone()) 195 + .ok_or_else(|| Error::Storage(format!("no account with handle {input}"))) 196 + } 197 + 198 + // --------------------------------------------------------------------------- 199 + // Storage trait 200 + // --------------------------------------------------------------------------- 201 + 202 + /// Platform-agnostic persistence for config, identity, and session data. 203 + /// 204 + /// Follows the `Transport` pattern: RPITIT, no Send bound, uses `crate::error::Error`. 205 + /// CLI implements this over the filesystem, web over IndexedDB. 206 + pub trait Storage { 207 + fn load_config(&self) -> impl std::future::Future<Output = Result<Config, Error>>; 208 + 209 + fn save_config(&self, config: &Config) -> impl std::future::Future<Output = Result<(), Error>>; 210 + 211 + fn load_identity( 212 + &self, 213 + did: &str, 214 + ) -> impl std::future::Future<Output = Result<Identity, Error>>; 215 + 216 + fn save_identity( 217 + &self, 218 + did: &str, 219 + identity: &Identity, 220 + ) -> impl std::future::Future<Output = Result<(), Error>>; 221 + 222 + fn load_session(&self, did: &str) -> impl std::future::Future<Output = Result<Session, Error>>; 223 + 224 + fn save_session( 225 + &self, 226 + did: &str, 227 + session: &Session, 228 + ) -> impl std::future::Future<Output = Result<(), Error>>; 229 + 230 + fn remove_account(&self, did: &str) -> impl std::future::Future<Output = Result<(), Error>>; 231 + } 232 + 233 + // --------------------------------------------------------------------------- 234 + // Tests 235 + // --------------------------------------------------------------------------- 236 + 237 + #[cfg(test)] 238 + mod tests { 239 + use super::*; 240 + 241 + #[test] 242 + fn sanitize_did_replaces_colons() { 243 + assert_eq!(sanitize_did("did:plc:abc123"), "did_plc_abc123"); 244 + } 245 + 246 + #[test] 247 + fn sanitize_did_handles_did_web() { 248 + assert_eq!(sanitize_did("did:web:example.com"), "did_web_example.com"); 249 + } 250 + 251 + #[test] 252 + fn resolve_handle_or_did_passes_did_through() { 253 + let config = Config { 254 + default_did: None, 255 + accounts: BTreeMap::new(), 256 + appview_url: None, 257 + }; 258 + let result = resolve_handle_or_did(&config, "did:plc:someone").unwrap(); 259 + assert_eq!(result, "did:plc:someone"); 260 + } 261 + 262 + #[test] 263 + fn resolve_handle_or_did_looks_up_handle() { 264 + let mut accounts = BTreeMap::new(); 265 + accounts.insert( 266 + "did:plc:alice".to_string(), 267 + AccountConfig { 268 + pds_url: "https://pds.test".into(), 269 + handle: "alice.test".into(), 270 + }, 271 + ); 272 + let config = Config { 273 + default_did: None, 274 + accounts, 275 + appview_url: None, 276 + }; 277 + let result = resolve_handle_or_did(&config, "alice.test").unwrap(); 278 + assert_eq!(result, "did:plc:alice"); 279 + } 280 + 281 + #[test] 282 + fn resolve_handle_or_did_unknown_handle_errors() { 283 + let config = Config { 284 + default_did: None, 285 + accounts: BTreeMap::new(), 286 + appview_url: None, 287 + }; 288 + let err = resolve_handle_or_did(&config, "nobody.test").unwrap_err(); 289 + assert!(err.to_string().contains("nobody.test")); 290 + } 291 + 292 + #[test] 293 + fn identity_public_key_bytes_roundtrip() { 294 + let identity = Identity { 295 + did: "did:plc:test".into(), 296 + public_key: BASE64.encode([1u8; 32]), 297 + private_key: BASE64.encode([2u8; 32]), 298 + signing_key: Some(BASE64.encode([3u8; 32])), 299 + verify_key: Some(BASE64.encode([4u8; 32])), 300 + }; 301 + assert_eq!(identity.public_key_bytes().unwrap(), [1u8; 32]); 302 + assert_eq!(identity.private_key_bytes().unwrap(), [2u8; 32]); 303 + assert_eq!(identity.signing_key_bytes().unwrap().unwrap(), [3u8; 32]); 304 + assert_eq!(identity.verify_key_bytes().unwrap().unwrap(), [4u8; 32]); 305 + } 306 + 307 + #[test] 308 + fn identity_rejects_bad_base64() { 309 + let identity = Identity { 310 + did: "did:plc:test".into(), 311 + public_key: "not!valid!base64!!!".into(), 312 + private_key: BASE64.encode([0u8; 32]), 313 + signing_key: None, 314 + verify_key: None, 315 + }; 316 + assert!(identity.public_key_bytes().is_err()); 317 + } 318 + 319 + #[test] 320 + fn identity_rejects_wrong_length() { 321 + let identity = Identity { 322 + did: "did:plc:test".into(), 323 + public_key: BASE64.encode([0u8; 16]), 324 + private_key: BASE64.encode([0u8; 32]), 325 + signing_key: None, 326 + verify_key: None, 327 + }; 328 + let err = identity.public_key_bytes().unwrap_err().to_string(); 329 + assert!(err.contains("16 bytes"), "expected length in error: {err}"); 330 + } 331 + 332 + #[test] 333 + fn has_signing_keys_requires_both() { 334 + let mut identity = Identity { 335 + did: "did:plc:test".into(), 336 + public_key: BASE64.encode([0u8; 32]), 337 + private_key: BASE64.encode([0u8; 32]), 338 + signing_key: None, 339 + verify_key: None, 340 + }; 341 + assert!(!identity.has_signing_keys()); 342 + 343 + identity.signing_key = Some(BASE64.encode([0u8; 32])); 344 + assert!(!identity.has_signing_keys()); 345 + 346 + identity.verify_key = Some(BASE64.encode([0u8; 32])); 347 + assert!(identity.has_signing_keys()); 348 + } 349 + 350 + #[test] 351 + fn config_default_is_empty() { 352 + let config = Config::default(); 353 + assert!(config.default_did.is_none()); 354 + assert!(config.accounts.is_empty()); 355 + assert!(config.appview_url.is_none()); 356 + } 357 + 358 + // -- Config mutation methods -- 359 + 360 + fn alice_account() -> AccountConfig { 361 + AccountConfig { 362 + pds_url: "https://pds.alice".into(), 363 + handle: "alice.test".into(), 364 + } 365 + } 366 + 367 + fn bob_account() -> AccountConfig { 368 + AccountConfig { 369 + pds_url: "https://pds.bob".into(), 370 + handle: "bob.test".into(), 371 + } 372 + } 373 + 374 + #[test] 375 + fn add_account_sets_default_if_first() { 376 + let mut config = Config::default(); 377 + config.add_account("did:plc:alice".into(), alice_account()); 378 + assert_eq!(config.default_did.as_deref(), Some("did:plc:alice")); 379 + assert_eq!(config.accounts.len(), 1); 380 + } 381 + 382 + #[test] 383 + fn add_account_preserves_existing_default() { 384 + let mut config = Config::default(); 385 + config.add_account("did:plc:alice".into(), alice_account()); 386 + config.add_account("did:plc:bob".into(), bob_account()); 387 + assert_eq!(config.default_did.as_deref(), Some("did:plc:alice")); 388 + assert_eq!(config.accounts.len(), 2); 389 + } 390 + 391 + #[test] 392 + fn remove_account_promotes_next_default() { 393 + let mut config = Config::default(); 394 + config.add_account("did:plc:alice".into(), alice_account()); 395 + config.add_account("did:plc:bob".into(), bob_account()); 396 + config.remove_account("did:plc:alice").unwrap(); 397 + assert_eq!(config.default_did.as_deref(), Some("did:plc:bob")); 398 + assert_eq!(config.accounts.len(), 1); 399 + } 400 + 401 + #[test] 402 + fn remove_account_clears_default_when_last() { 403 + let mut config = Config::default(); 404 + config.add_account("did:plc:alice".into(), alice_account()); 405 + config.remove_account("did:plc:alice").unwrap(); 406 + assert!(config.default_did.is_none()); 407 + assert!(config.accounts.is_empty()); 408 + } 409 + 410 + #[test] 411 + fn remove_account_unknown_did_errors() { 412 + let mut config = Config::default(); 413 + let err = config.remove_account("did:plc:nobody").unwrap_err(); 414 + assert!(err.to_string().contains("did:plc:nobody")); 415 + } 416 + 417 + #[test] 418 + fn set_default_validates_did_exists() { 419 + let mut config = Config::default(); 420 + config.add_account("did:plc:alice".into(), alice_account()); 421 + config.add_account("did:plc:bob".into(), bob_account()); 422 + config.set_default("did:plc:bob").unwrap(); 423 + assert_eq!(config.default_did.as_deref(), Some("did:plc:bob")); 424 + } 425 + 426 + #[test] 427 + fn set_default_unknown_did_errors() { 428 + let mut config = Config::default(); 429 + let err = config.set_default("did:plc:nobody").unwrap_err(); 430 + assert!(err.to_string().contains("did:plc:nobody")); 431 + } 432 + 433 + // -- Identity generation -- 434 + 435 + use crate::crypto::OsRng; 436 + 437 + #[test] 438 + fn generate_produces_valid_identity() { 439 + let identity = Identity::generate("did:plc:test", &mut OsRng); 440 + assert_eq!(identity.did, "did:plc:test"); 441 + assert_eq!(identity.public_key_bytes().unwrap().len(), 32); 442 + assert_eq!(identity.private_key_bytes().unwrap().len(), 32); 443 + } 444 + 445 + #[test] 446 + fn generate_always_has_signing_keys() { 447 + let identity = Identity::generate("did:plc:test", &mut OsRng); 448 + assert!(identity.has_signing_keys()); 449 + assert!(identity.signing_key_bytes().unwrap().is_some()); 450 + assert!(identity.verify_key_bytes().unwrap().is_some()); 451 + } 452 + 453 + #[test] 454 + fn ensure_signing_keys_adds_when_missing() { 455 + let mut identity = Identity { 456 + did: "did:plc:test".into(), 457 + public_key: BASE64.encode([1u8; 32]), 458 + private_key: BASE64.encode([2u8; 32]), 459 + signing_key: None, 460 + verify_key: None, 461 + }; 462 + assert!(!identity.has_signing_keys()); 463 + let added = identity.ensure_signing_keys(&mut OsRng); 464 + assert!(added); 465 + assert!(identity.has_signing_keys()); 466 + } 467 + 468 + #[test] 469 + fn ensure_signing_keys_noop_when_present() { 470 + let mut identity = Identity::generate("did:plc:test", &mut OsRng); 471 + let original_sk = identity.signing_key.clone(); 472 + let added = identity.ensure_signing_keys(&mut OsRng); 473 + assert!(!added); 474 + assert_eq!(identity.signing_key, original_sk); 475 + } 476 + }
+6
web/bun.lock
··· 8 8 "@phosphor-icons/react": "^2.1.10", 9 9 "@tanstack/react-router": "^1.163.3", 10 10 "comlink": "^4.4.2", 11 + "dexie": "^4.3.0", 11 12 "react": "^19.2.4", 12 13 "react-dom": "^19.2.4", 13 14 "zustand": "^5.0.11", ··· 21 22 "@types/react-dom": "^19.2.3", 22 23 "@vitejs/plugin-react": "^5.1.4", 23 24 "daisyui": "^5.5.19", 25 + "fake-indexeddb": "^6.2.5", 24 26 "happy-dom": "^20.8.3", 25 27 "tailwindcss": "^4.2.1", 26 28 "typescript": "^5.9.3", ··· 342 344 343 345 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 344 346 347 + "dexie": ["dexie@4.3.0", "", {}, "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug=="], 348 + 345 349 "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], 346 350 347 351 "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], ··· 363 367 "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], 364 368 365 369 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 370 + 371 + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], 366 372 367 373 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 368 374
+2
web/package.json
··· 16 16 "@phosphor-icons/react": "^2.1.10", 17 17 "@tanstack/react-router": "^1.163.3", 18 18 "comlink": "^4.4.2", 19 + "dexie": "^4.3.0", 19 20 "react": "^19.2.4", 20 21 "react-dom": "^19.2.4", 21 22 "zustand": "^5.0.11" ··· 29 30 "@types/react-dom": "^19.2.3", 30 31 "@vitejs/plugin-react": "^5.1.4", 31 32 "daisyui": "^5.5.19", 33 + "fake-indexeddb": "^6.2.5", 32 34 "happy-dom": "^20.8.3", 33 35 "tailwindcss": "^4.2.1", 34 36 "typescript": "^5.9.3",
+116
web/src/lib/indexeddb-storage.ts
··· 1 + // IndexedDB-backed Storage implementation using Dexie.js. 2 + // Mirrors: crates/opake-cli/src/config.rs — FileStorage (but for the browser) 3 + 4 + import Dexie, { type EntityTable } from "dexie"; 5 + import type { Config, Identity, Session } from "./storage-types"; 6 + import { type Storage, StorageError, sanitizeDid } from "./storage"; 7 + 8 + const CONFIG_KEY = "global"; 9 + 10 + interface ConfigRow { 11 + key: string; 12 + value: Config; 13 + } 14 + 15 + interface IdentityRow { 16 + did: string; 17 + value: Identity; 18 + } 19 + 20 + interface SessionRow { 21 + did: string; 22 + value: Session; 23 + } 24 + 25 + class OpakeDatabase extends Dexie { 26 + configs!: EntityTable<ConfigRow, "key">; 27 + identities!: EntityTable<IdentityRow, "did">; 28 + sessions!: EntityTable<SessionRow, "did">; 29 + 30 + constructor(name = "opake") { 31 + super(name); 32 + this.version(1).stores({ 33 + configs: "key", 34 + identities: "did", 35 + sessions: "did", 36 + }); 37 + } 38 + } 39 + 40 + export class IndexedDbStorage implements Storage { 41 + private db: OpakeDatabase; 42 + 43 + constructor(dbName = "opake") { 44 + this.db = new OpakeDatabase(dbName); 45 + } 46 + 47 + async loadConfig(): Promise<Config> { 48 + const row = await this.db.configs.get(CONFIG_KEY); 49 + if (!row) { 50 + throw new StorageError("no config found — log in first"); 51 + } 52 + return row.value; 53 + } 54 + 55 + async saveConfig(config: Config): Promise<void> { 56 + await this.db.configs.put({ key: CONFIG_KEY, value: config }); 57 + } 58 + 59 + async loadIdentity(did: string): Promise<Identity> { 60 + const key = sanitizeDid(did); 61 + const row = await this.db.identities.get(key); 62 + if (!row) { 63 + throw new StorageError(`no identity for ${did} — log in first`); 64 + } 65 + return row.value; 66 + } 67 + 68 + async saveIdentity(did: string, identity: Identity): Promise<void> { 69 + const key = sanitizeDid(did); 70 + await this.db.identities.put({ did: key, value: identity }); 71 + } 72 + 73 + async loadSession(did: string): Promise<Session> { 74 + const key = sanitizeDid(did); 75 + const row = await this.db.sessions.get(key); 76 + if (!row) { 77 + throw new StorageError(`no session for ${did} — log in first`); 78 + } 79 + return row.value; 80 + } 81 + 82 + async saveSession(did: string, session: Session): Promise<void> { 83 + const key = sanitizeDid(did); 84 + await this.db.sessions.put({ did: key, value: session }); 85 + } 86 + 87 + async removeAccount(did: string): Promise<void> { 88 + const config = await this.loadConfig(); 89 + delete config.accounts[did]; 90 + if (config.defaultDid === did) { 91 + const remaining = Object.keys(config.accounts); 92 + config.defaultDid = remaining.length > 0 ? remaining[0]! : null; 93 + } 94 + const key = sanitizeDid(did); 95 + await this.db.transaction( 96 + "rw", 97 + [this.db.configs, this.db.identities, this.db.sessions], 98 + async () => { 99 + await this.db.configs.put({ key: CONFIG_KEY, value: config }); 100 + await this.db.identities.delete(key); 101 + await this.db.sessions.delete(key); 102 + }, 103 + ); 104 + } 105 + 106 + /** Close the database connection. Useful for test cleanup. */ 107 + close(): void { 108 + this.db.close(); 109 + } 110 + 111 + /** Delete the entire database. Useful for test cleanup. */ 112 + async destroy(): Promise<void> { 113 + this.db.close(); 114 + await this.db.delete(); 115 + } 116 + }
+28
web/src/lib/storage-types.ts
··· 1 + // TypeScript equivalents of opake-core storage types. 2 + // Mirrors: crates/opake-core/src/storage.rs 3 + 4 + export interface Config { 5 + defaultDid: string | null; 6 + accounts: Record<string, AccountConfig>; 7 + appviewUrl: string | null; 8 + } 9 + 10 + export interface AccountConfig { 11 + pdsUrl: string; 12 + handle: string; 13 + } 14 + 15 + export interface Identity { 16 + did: string; 17 + publicKey: string; // base64 X25519 18 + privateKey: string; // base64 X25519 19 + signingKey: string | null; // base64 Ed25519 20 + verifyKey: string | null; // base64 Ed25519 21 + } 22 + 23 + export interface Session { 24 + did: string; 25 + handle: string; 26 + accessJwt: string; 27 + refreshJwt: string; 28 + }
+26
web/src/lib/storage.ts
··· 1 + // Platform-agnostic storage contract. 2 + // Mirrors: crates/opake-core/src/storage.rs — Storage trait 3 + 4 + import type { Config, Identity, Session } from "./storage-types"; 5 + 6 + export interface Storage { 7 + loadConfig(): Promise<Config>; 8 + saveConfig(config: Config): Promise<void>; 9 + loadIdentity(did: string): Promise<Identity>; 10 + saveIdentity(did: string, identity: Identity): Promise<void>; 11 + loadSession(did: string): Promise<Session>; 12 + saveSession(did: string, session: Session): Promise<void>; 13 + removeAccount(did: string): Promise<void>; 14 + } 15 + 16 + export class StorageError extends Error { 17 + constructor(message: string) { 18 + super(message); 19 + this.name = "StorageError"; 20 + } 21 + } 22 + 23 + /** `did:plc:abc` → `did_plc_abc` — mirrors `sanitize_did` in opake-core. */ 24 + export function sanitizeDid(did: string): string { 25 + return did.replaceAll(":", "_"); 26 + }
+210
web/tests/lib/indexeddb-storage.test.ts
··· 1 + import "fake-indexeddb/auto"; 2 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 3 + import { IndexedDbStorage } from "../../src/lib/indexeddb-storage"; 4 + import { StorageError } from "../../src/lib/storage"; 5 + import type { Config, Identity, Session } from "../../src/lib/storage-types"; 6 + 7 + let storage: IndexedDbStorage; 8 + let dbCounter = 0; 9 + 10 + function uniqueDbName(): string { 11 + return `opake-test-${++dbCounter}-${Date.now()}`; 12 + } 13 + 14 + const testConfig: Config = { 15 + defaultDid: "did:plc:alice", 16 + accounts: { 17 + "did:plc:alice": { pdsUrl: "https://pds.alice", handle: "alice.test" }, 18 + }, 19 + appviewUrl: null, 20 + }; 21 + 22 + const testIdentity: Identity = { 23 + did: "did:plc:alice", 24 + publicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 25 + privateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", 26 + signingKey: "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", 27 + verifyKey: "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=", 28 + }; 29 + 30 + const testSession: Session = { 31 + did: "did:plc:alice", 32 + handle: "alice.test", 33 + accessJwt: "eyJ.access.token", 34 + refreshJwt: "eyJ.refresh.token", 35 + }; 36 + 37 + beforeEach(() => { 38 + storage = new IndexedDbStorage(uniqueDbName()); 39 + }); 40 + 41 + afterEach(async () => { 42 + await storage.destroy(); 43 + }); 44 + 45 + // -- Config ------------------------------------------------------------------- 46 + 47 + describe("config", () => { 48 + it("save and load roundtrip", async () => { 49 + await storage.saveConfig(testConfig); 50 + const loaded = await storage.loadConfig(); 51 + expect(loaded).toEqual(testConfig); 52 + }); 53 + 54 + it("load without save throws StorageError", async () => { 55 + await expect(storage.loadConfig()).rejects.toThrow(StorageError); 56 + }); 57 + 58 + it("save overwrites previous config", async () => { 59 + await storage.saveConfig(testConfig); 60 + const updated: Config = { ...testConfig, defaultDid: "did:plc:bob" }; 61 + await storage.saveConfig(updated); 62 + const loaded = await storage.loadConfig(); 63 + expect(loaded.defaultDid).toBe("did:plc:bob"); 64 + }); 65 + 66 + it("handles multiple accounts", async () => { 67 + const config: Config = { 68 + defaultDid: "did:plc:alice", 69 + accounts: { 70 + "did:plc:alice": { pdsUrl: "https://pds.alice", handle: "alice.test" }, 71 + "did:plc:bob": { pdsUrl: "https://pds.bob", handle: "bob.test" }, 72 + }, 73 + appviewUrl: "https://appview.test", 74 + }; 75 + await storage.saveConfig(config); 76 + const loaded = await storage.loadConfig(); 77 + expect(Object.keys(loaded.accounts)).toHaveLength(2); 78 + expect(loaded.accounts["did:plc:bob"]?.handle).toBe("bob.test"); 79 + expect(loaded.appviewUrl).toBe("https://appview.test"); 80 + }); 81 + }); 82 + 83 + // -- Identity ----------------------------------------------------------------- 84 + 85 + describe("identity", () => { 86 + it("save and load roundtrip", async () => { 87 + await storage.saveIdentity("did:plc:alice", testIdentity); 88 + const loaded = await storage.loadIdentity("did:plc:alice"); 89 + expect(loaded).toEqual(testIdentity); 90 + }); 91 + 92 + it("load missing identity throws StorageError", async () => { 93 + await expect(storage.loadIdentity("did:plc:nobody")).rejects.toThrow( 94 + StorageError, 95 + ); 96 + }); 97 + 98 + it("overwrite preserves latest", async () => { 99 + await storage.saveIdentity("did:plc:alice", testIdentity); 100 + const updated: Identity = { ...testIdentity, publicKey: "NEWKEY=" }; 101 + await storage.saveIdentity("did:plc:alice", updated); 102 + const loaded = await storage.loadIdentity("did:plc:alice"); 103 + expect(loaded.publicKey).toBe("NEWKEY="); 104 + }); 105 + 106 + it("different DIDs are independent", async () => { 107 + const bobIdentity: Identity = { ...testIdentity, did: "did:plc:bob" }; 108 + await storage.saveIdentity("did:plc:alice", testIdentity); 109 + await storage.saveIdentity("did:plc:bob", bobIdentity); 110 + const alice = await storage.loadIdentity("did:plc:alice"); 111 + const bob = await storage.loadIdentity("did:plc:bob"); 112 + expect(alice.did).toBe("did:plc:alice"); 113 + expect(bob.did).toBe("did:plc:bob"); 114 + }); 115 + }); 116 + 117 + // -- Session ------------------------------------------------------------------ 118 + 119 + describe("session", () => { 120 + it("save and load roundtrip", async () => { 121 + await storage.saveSession("did:plc:alice", testSession); 122 + const loaded = await storage.loadSession("did:plc:alice"); 123 + expect(loaded).toEqual(testSession); 124 + }); 125 + 126 + it("load missing session throws StorageError", async () => { 127 + await expect(storage.loadSession("did:plc:nobody")).rejects.toThrow( 128 + StorageError, 129 + ); 130 + }); 131 + 132 + it("overwrite preserves latest", async () => { 133 + await storage.saveSession("did:plc:alice", testSession); 134 + const refreshed: Session = { ...testSession, accessJwt: "new.jwt" }; 135 + await storage.saveSession("did:plc:alice", refreshed); 136 + const loaded = await storage.loadSession("did:plc:alice"); 137 + expect(loaded.accessJwt).toBe("new.jwt"); 138 + }); 139 + }); 140 + 141 + // -- removeAccount ------------------------------------------------------------ 142 + 143 + const multiAccountConfig: Config = { 144 + defaultDid: "did:plc:alice", 145 + accounts: { 146 + "did:plc:alice": { pdsUrl: "https://pds.alice", handle: "alice.test" }, 147 + "did:plc:bob": { pdsUrl: "https://pds.bob", handle: "bob.test" }, 148 + }, 149 + appviewUrl: null, 150 + }; 151 + 152 + describe("removeAccount", () => { 153 + it("removes identity, session, and config entry", async () => { 154 + await storage.saveConfig(multiAccountConfig); 155 + await storage.saveIdentity("did:plc:alice", testIdentity); 156 + await storage.saveSession("did:plc:alice", testSession); 157 + 158 + await storage.removeAccount("did:plc:alice"); 159 + 160 + await expect(storage.loadIdentity("did:plc:alice")).rejects.toThrow( 161 + StorageError, 162 + ); 163 + await expect(storage.loadSession("did:plc:alice")).rejects.toThrow( 164 + StorageError, 165 + ); 166 + const config = await storage.loadConfig(); 167 + expect(config.accounts["did:plc:alice"]).toBeUndefined(); 168 + }); 169 + 170 + it("promotes next default when removing the current default", async () => { 171 + await storage.saveConfig(multiAccountConfig); 172 + await storage.saveIdentity("did:plc:alice", testIdentity); 173 + await storage.saveSession("did:plc:alice", testSession); 174 + 175 + await storage.removeAccount("did:plc:alice"); 176 + 177 + const config = await storage.loadConfig(); 178 + expect(config.defaultDid).toBe("did:plc:bob"); 179 + expect(Object.keys(config.accounts)).toHaveLength(1); 180 + }); 181 + 182 + it("clears default when removing the last account", async () => { 183 + await storage.saveConfig(testConfig); 184 + await storage.saveIdentity("did:plc:alice", testIdentity); 185 + await storage.saveSession("did:plc:alice", testSession); 186 + 187 + await storage.removeAccount("did:plc:alice"); 188 + 189 + const config = await storage.loadConfig(); 190 + expect(config.defaultDid).toBeNull(); 191 + expect(Object.keys(config.accounts)).toHaveLength(0); 192 + }); 193 + 194 + it("does not affect other accounts", async () => { 195 + await storage.saveConfig(multiAccountConfig); 196 + const bobIdentity: Identity = { ...testIdentity, did: "did:plc:bob" }; 197 + const bobSession: Session = { ...testSession, did: "did:plc:bob" }; 198 + await storage.saveIdentity("did:plc:alice", testIdentity); 199 + await storage.saveSession("did:plc:alice", testSession); 200 + await storage.saveIdentity("did:plc:bob", bobIdentity); 201 + await storage.saveSession("did:plc:bob", bobSession); 202 + 203 + await storage.removeAccount("did:plc:alice"); 204 + 205 + const bob = await storage.loadIdentity("did:plc:bob"); 206 + expect(bob.did).toBe("did:plc:bob"); 207 + const bobSess = await storage.loadSession("did:plc:bob"); 208 + expect(bobSess.did).toBe("did:plc:bob"); 209 + }); 210 + });