An encrypted personal cloud built on the AT Protocol.

Encrypt directory metadata with AES-256-GCM

Replace plaintext name on directory records with encrypted metadata
and encryption envelope, matching the pattern from documents (#187)
and keyrings/grants (#188). Not backwards-compatible — old directory
records will be silently skipped.

sans-self.org b190989b 700a6e5c

Waiting for spindle ...
+400 -71
+1
CHANGELOG.md
··· 66 66 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 67 67 68 68 ### Changed 69 + - Encrypt directory metadata (add encryption envelope to directory records) [#189](https://issues.opake.app/issues/189.html) 69 70 - Encrypt keyring and grant metadata [#188](https://issues.opake.app/issues/188.html) 70 71 - Encrypt document metadata (name, mimeType, tags, description) [#187](https://issues.opake.app/issues/187.html) 71 72 - Rename collection NSIDs from app.opake.cloud.* to app.opake.* and version to opakeVersion [#186](https://issues.opake.app/issues/186.html)
+2 -1
crates/opake-cli/src/commands/cat.rs
··· 31 31 self.reference.clone() 32 32 } else if self.reference.contains('/') { 33 33 // Path — needs the directory tree. 34 - let tree = DirectoryTree::load(&mut client).await?; 34 + let mut tree = DirectoryTree::load(&mut client).await?; 35 + tree.decrypt_names(&ctx.did, &private_key); 35 36 let resolved = tree.resolve(&mut client, &self.reference).await?; 36 37 resolved.uri 37 38 } else {
+45 -2
crates/opake-cli/src/commands/mkdir.rs
··· 2 2 use chrono::Utc; 3 3 use clap::Args; 4 4 use opake_core::client::Session; 5 + use opake_core::crypto::{self, DirectoryMetadata, OsRng}; 5 6 use opake_core::directories; 7 + use opake_core::records::{DirectEncryption, Encryption, EncryptionEnvelope}; 6 8 7 9 use crate::commands::Execute; 10 + use crate::identity; 8 11 use crate::session::{self, CommandContext}; 9 12 10 13 #[derive(Args)] ··· 14 17 name: String, 15 18 } 16 19 20 + /// Build a direct encryption envelope for a directory. 21 + /// 22 + /// Generates a fresh content key, encrypts the metadata with it, and wraps 23 + /// the key to the owner's public key. Returns the encryption enum and 24 + /// encrypted metadata for the directory record. 25 + fn encrypt_directory( 26 + name: &str, 27 + owner_did: &str, 28 + owner_pubkey: &crypto::X25519PublicKey, 29 + rng: &mut (impl crypto::CryptoRng + crypto::RngCore), 30 + ) -> Result<(Encryption, opake_core::records::EncryptedMetadata)> { 31 + let content_key = crypto::generate_content_key(rng); 32 + 33 + let metadata = DirectoryMetadata { 34 + name: name.into(), 35 + description: None, 36 + }; 37 + let encrypted_metadata = crypto::encrypt_metadata(&content_key, &metadata, rng)?; 38 + 39 + let wrapped_key = crypto::wrap_key(&content_key, owner_pubkey, owner_did, rng)?; 40 + 41 + let encryption = Encryption::Direct(DirectEncryption { 42 + envelope: EncryptionEnvelope { 43 + algo: "aes-256-gcm".into(), 44 + nonce: opake_core::records::AtBytes::from_raw(&[0u8; 12]), 45 + keys: vec![wrapped_key], 46 + }, 47 + }); 48 + 49 + Ok((encryption, encrypted_metadata)) 50 + } 51 + 17 52 impl Execute for MkdirCommand { 18 53 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 19 54 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 55 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 56 + let pubkey = id.public_key_bytes()?; 20 57 let now = Utc::now().to_rfc3339(); 21 58 22 - let root_uri = directories::get_or_create_root(&mut client, &ctx.did, &now).await?; 23 - let directory_uri = directories::create_directory(&mut client, &self.name, &now).await?; 59 + let (root_enc, root_meta) = encrypt_directory("/", &ctx.did, &pubkey, &mut OsRng)?; 60 + let root_uri = 61 + directories::get_or_create_root(&mut client, &ctx.did, root_enc, root_meta, &now) 62 + .await?; 63 + 64 + let (dir_enc, dir_meta) = encrypt_directory(&self.name, &ctx.did, &pubkey, &mut OsRng)?; 65 + let directory_uri = 66 + directories::create_directory(&mut client, dir_enc, dir_meta, &now).await?; 24 67 directories::add_entry(&mut client, &root_uri, &directory_uri, &now).await?; 25 68 26 69 println!("{} → {}", self.name, directory_uri);
+5 -1
crates/opake-cli/src/commands/move_cmd.rs
··· 5 5 use opake_core::directories::{check_cycle, move_entry, DirectoryTree, EntryKind}; 6 6 7 7 use crate::commands::Execute; 8 + use crate::identity; 8 9 use crate::session::{self, CommandContext}; 9 10 10 11 #[derive(Args)] ··· 20 21 impl Execute for MoveCommand { 21 22 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 22 23 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 24 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 25 + let private_key = id.private_key_bytes()?; 23 26 let now = Utc::now().to_rfc3339(); 24 27 25 - let tree = DirectoryTree::load(&mut client).await?; 28 + let mut tree = DirectoryTree::load(&mut client).await?; 29 + tree.decrypt_names(&ctx.did, &private_key); 26 30 let source = tree.resolve(&mut client, &self.source).await?; 27 31 let dest = tree.resolve(&mut client, &self.destination).await?; 28 32
+2 -1
crates/opake-cli/src/commands/rm.rs
··· 117 117 } 118 118 119 119 // Full tree path: paths, directories, recursive deletion. 120 - let tree = DirectoryTree::load(&mut client).await?; 120 + let mut tree = DirectoryTree::load(&mut client).await?; 121 + tree.decrypt_names(&ctx.did, &private_key); 121 122 let resolved = tree.resolve(&mut client, &self.reference).await?; 122 123 123 124 if !self.yes {
+3 -1
crates/opake-cli/src/commands/tree.rs
··· 18 18 impl Execute for TreeCommand { 19 19 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 20 20 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 21 - let tree = DirectoryTree::load(&mut client).await?; 21 + let mut tree = DirectoryTree::load(&mut client).await?; 22 22 23 23 let entries = documents::list_documents(&mut client).await?; 24 24 let id = identity::load_identity(&ctx.storage, &ctx.did)?; 25 25 let private_key = id.private_key_bytes()?; 26 + 27 + tree.decrypt_names(&ctx.did, &private_key); 26 28 27 29 let documents: HashMap<String, String> = entries 28 30 .iter()
+4 -1
crates/opake-cli/src/commands/upload.rs
··· 99 99 }; 100 100 101 101 if let Some(dir_path) = &self.dir { 102 - let tree = DirectoryTree::load(&mut client).await?; 102 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 103 + let private_key = id.private_key_bytes()?; 104 + let mut tree = DirectoryTree::load(&mut client).await?; 105 + tree.decrypt_names(&ctx.did, &private_key); 103 106 let resolved = tree.resolve(&mut client, dir_path).await?; 104 107 105 108 if resolved.kind != EntryKind::Directory {
+9
crates/opake-core/src/crypto/metadata.rs
··· 49 49 pub note: Option<String>, 50 50 } 51 51 52 + /// Plaintext directory metadata. Encrypted with the directory's content key. 53 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 54 + #[serde(rename_all = "camelCase")] 55 + pub struct DirectoryMetadata { 56 + pub name: String, 57 + #[serde(skip_serializing_if = "Option::is_none")] 58 + pub description: Option<String>, 59 + } 60 + 52 61 // --------------------------------------------------------------------------- 53 62 // Generic encrypt / decrypt 54 63 // ---------------------------------------------------------------------------
+30
crates/opake-core/src/crypto/metadata_tests.rs
··· 141 141 assert!(decrypted.permissions.is_none()); 142 142 assert!(decrypted.note.is_none()); 143 143 } 144 + 145 + #[test] 146 + fn directory_metadata_roundtrip() { 147 + let key = generate_content_key(&mut OsRng); 148 + let metadata = DirectoryMetadata { 149 + name: "Photos".into(), 150 + description: Some("Vacation photos".into()), 151 + }; 152 + 153 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 154 + let decrypted: DirectoryMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 155 + 156 + assert_eq!(decrypted.name, "Photos"); 157 + assert_eq!(decrypted.description.as_deref(), Some("Vacation photos")); 158 + } 159 + 160 + #[test] 161 + fn directory_metadata_minimal() { 162 + let key = generate_content_key(&mut OsRng); 163 + let metadata = DirectoryMetadata { 164 + name: "/".into(), 165 + description: None, 166 + }; 167 + 168 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 169 + let decrypted: DirectoryMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 170 + 171 + assert_eq!(decrypted.name, "/"); 172 + assert!(decrypted.description.is_none()); 173 + }
+2 -1
crates/opake-core/src/crypto/mod.rs
··· 31 31 pub use key_wrapping::{create_group_key, unwrap_key, wrap_key}; 32 32 pub use keyring_wrapping::{unwrap_content_key_from_keyring, wrap_content_key_for_keyring}; 33 33 pub use metadata::{ 34 - decrypt_metadata, encrypt_metadata, DocumentMetadata, GrantMetadata, KeyringMetadata, 34 + decrypt_metadata, encrypt_metadata, DirectoryMetadata, DocumentMetadata, GrantMetadata, 35 + KeyringMetadata, 35 36 }; 36 37 37 38 const WRAP_ALGO: &str = "x25519-hkdf-a256kw";
+28 -12
crates/opake-core/src/directories/create.rs
··· 2 2 3 3 use crate::client::{Transport, XrpcClient}; 4 4 use crate::error::Error; 5 - use crate::records::Directory; 5 + use crate::records::{Directory, EncryptedMetadata, Encryption}; 6 6 7 7 use super::DIRECTORY_COLLECTION; 8 8 9 9 /// Create a new directory record. Returns its AT-URI. 10 + /// 11 + /// Callers are responsible for encrypting the directory metadata and wrapping 12 + /// the content key before calling this function. 10 13 pub async fn create_directory( 11 14 client: &mut XrpcClient<impl Transport>, 12 - name: &str, 15 + encryption: Encryption, 16 + encrypted_metadata: EncryptedMetadata, 13 17 created_at: &str, 14 18 ) -> Result<String, Error> { 15 - let directory = Directory::new(name.to_string(), created_at.to_string()); 19 + let directory = Directory::new(encryption, encrypted_metadata, created_at.to_string()); 16 20 17 - debug!("creating directory {:?}", name); 21 + debug!("creating directory"); 18 22 let record_ref = client 19 23 .create_record(DIRECTORY_COLLECTION, &directory) 20 24 .await?; ··· 29 33 use crate::records::Directory; 30 34 use crate::test_utils::MockTransport; 31 35 32 - use super::super::tests::{create_record_response, mock_client, TEST_DID}; 36 + use super::super::tests::{create_record_response, dummy_directory, mock_client, TEST_DID}; 33 37 34 38 #[tokio::test] 35 39 async fn happy_path() { ··· 37 41 let mock = MockTransport::new(); 38 42 mock.enqueue(create_record_response(&uri)); 39 43 44 + let dir = dummy_directory("Photos"); 40 45 let mut client = mock_client(mock.clone()); 41 - let result = create_directory(&mut client, "Photos", "2026-03-01T00:00:00Z") 42 - .await 43 - .unwrap(); 46 + let result = create_directory( 47 + &mut client, 48 + dir.encryption, 49 + dir.encrypted_metadata, 50 + "2026-03-01T00:00:00Z", 51 + ) 52 + .await 53 + .unwrap(); 44 54 45 55 assert_eq!(result, uri); 46 56 ··· 52 62 Some(RequestBody::Json(v)) => { 53 63 assert_eq!(v["collection"], "app.opake.directory"); 54 64 let record: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 55 - assert_eq!(record.name, "Photos"); 65 + assert!(matches!(record.encryption, Encryption::Direct(_))); 56 66 assert!(record.entries.is_empty()); 57 67 } 58 68 _ => panic!("expected JSON body"), ··· 68 78 body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 69 79 }); 70 80 81 + let dir = dummy_directory("Broken"); 71 82 let mut client = mock_client(mock); 72 - let err = create_directory(&mut client, "Broken", "2026-03-01T00:00:00Z") 73 - .await 74 - .unwrap_err(); 83 + let err = create_directory( 84 + &mut client, 85 + dir.encryption, 86 + dir.encrypted_metadata, 87 + "2026-03-01T00:00:00Z", 88 + ) 89 + .await 90 + .unwrap_err(); 75 91 assert!(matches!(err, Error::Xrpc { .. })); 76 92 } 77 93 }
+39 -13
crates/opake-core/src/directories/get_or_create_root.rs
··· 2 2 3 3 use crate::client::{Transport, XrpcClient}; 4 4 use crate::error::Error; 5 - use crate::records::Directory; 5 + use crate::records::{Directory, EncryptedMetadata, Encryption}; 6 6 7 - use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_NAME, ROOT_DIRECTORY_RKEY}; 7 + use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY}; 8 8 9 9 /// Get the root directory's AT-URI, creating it if it doesn't exist. 10 10 /// 11 11 /// The root directory is a singleton at rkey "self" with name "/". 12 12 /// Uses `put_record` for creation (idempotent upsert with explicit rkey). 13 + /// 14 + /// Callers provide the pre-encrypted metadata and encryption envelope for 15 + /// root creation. If the root already exists, these are unused. 13 16 pub async fn get_or_create_root( 14 17 client: &mut XrpcClient<impl Transport>, 15 18 did: &str, 19 + encryption: Encryption, 20 + encrypted_metadata: EncryptedMetadata, 16 21 created_at: &str, 17 22 ) -> Result<String, Error> { 18 23 debug!("checking for root directory"); ··· 26 31 } 27 32 Err(Error::NotFound(_)) => { 28 33 debug!("root directory not found, creating"); 29 - let root = Directory::new(ROOT_DIRECTORY_NAME.to_string(), created_at.to_string()); 34 + let root = Directory::new(encryption, encrypted_metadata, created_at.to_string()); 30 35 let record_ref = client 31 36 .put_record(DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY, &root) 32 37 .await?; ··· 56 61 let mock = MockTransport::new(); 57 62 mock.enqueue(get_record_response(ROOT_URI, &root)); 58 63 64 + let dir = dummy_directory("/"); 59 65 let mut client = mock_client(mock.clone()); 60 - let uri = get_or_create_root(&mut client, TEST_DID, "2026-03-01T00:00:00Z") 61 - .await 62 - .unwrap(); 66 + let uri = get_or_create_root( 67 + &mut client, 68 + TEST_DID, 69 + dir.encryption, 70 + dir.encrypted_metadata, 71 + "2026-03-01T00:00:00Z", 72 + ) 73 + .await 74 + .unwrap(); 63 75 64 76 assert_eq!(uri, ROOT_URI); 65 77 ··· 74 86 mock.enqueue(not_found_response()); 75 87 mock.enqueue(put_record_response(ROOT_URI)); 76 88 89 + let dir = dummy_directory("/"); 77 90 let mut client = mock_client(mock.clone()); 78 - let uri = get_or_create_root(&mut client, TEST_DID, "2026-03-01T00:00:00Z") 79 - .await 80 - .unwrap(); 91 + let uri = get_or_create_root( 92 + &mut client, 93 + TEST_DID, 94 + dir.encryption, 95 + dir.encrypted_metadata, 96 + "2026-03-01T00:00:00Z", 97 + ) 98 + .await 99 + .unwrap(); 81 100 82 101 assert_eq!(uri, ROOT_URI); 83 102 ··· 90 109 Some(RequestBody::Json(v)) => { 91 110 assert_eq!(v["rkey"], "self"); 92 111 let record: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 93 - assert_eq!(record.name, "/"); 112 + assert!(matches!(record.encryption, Encryption::Direct(_))); 94 113 assert!(record.entries.is_empty()); 95 114 } 96 115 _ => panic!("expected JSON body"), ··· 106 125 body: br#"{"error":"InternalServerError","message":"boom"}"#.to_vec(), 107 126 }); 108 127 128 + let dir = dummy_directory("/"); 109 129 let mut client = mock_client(mock); 110 - let err = get_or_create_root(&mut client, TEST_DID, "2026-03-01T00:00:00Z") 111 - .await 112 - .unwrap_err(); 130 + let err = get_or_create_root( 131 + &mut client, 132 + TEST_DID, 133 + dir.encryption, 134 + dir.encrypted_metadata, 135 + "2026-03-01T00:00:00Z", 136 + ) 137 + .await 138 + .unwrap_err(); 113 139 assert!(matches!(err, Error::Xrpc { .. })); 114 140 } 115 141 }
+8 -9
crates/opake-core/src/directories/list.rs
··· 1 1 use crate::client::{list_collection, Transport, XrpcClient}; 2 2 use crate::error::Error; 3 - use crate::records::Directory; 3 + use crate::records::{Directory, EncryptedMetadata, Encryption}; 4 4 5 5 use super::DIRECTORY_COLLECTION; 6 6 7 - /// A directory listing entry with its AT-URI and parsed metadata. 7 + /// A directory listing entry with its AT-URI and encrypted metadata. 8 + /// Callers decrypt the name using the encryption envelope. 8 9 #[derive(Debug)] 9 10 pub struct DirectoryEntry { 10 11 pub uri: String, 11 - pub name: String, 12 + pub encryption: Encryption, 13 + pub encrypted_metadata: EncryptedMetadata, 12 14 pub entry_count: usize, 13 15 pub created_at: String, 14 16 } ··· 22 24 list_collection(client, DIRECTORY_COLLECTION, |uri, directory: Directory| { 23 25 DirectoryEntry { 24 26 uri: uri.to_owned(), 25 - name: directory.name, 27 + encryption: directory.encryption, 28 + encrypted_metadata: directory.encrypted_metadata, 26 29 entry_count: directory.entries.len(), 27 30 created_at: directory.created_at, 28 31 } ··· 53 56 let entries = list_directories(&mut client).await.unwrap(); 54 57 55 58 assert_eq!(entries.len(), 1); 56 - assert_eq!(entries[0].name, "Photos"); 57 59 assert_eq!(entries[0].entry_count, 0); 58 60 assert!(entries[0].uri.contains("dir1")); 61 + assert!(matches!(entries[0].encryption, Encryption::Direct(_))); 59 62 60 63 let reqs = mock.requests(); 61 64 assert!(reqs[0].url.contains("app.opake.directory")); ··· 80 83 let entries = list_directories(&mut client).await.unwrap(); 81 84 82 85 assert_eq!(entries.len(), 2); 83 - assert_eq!(entries[0].name, "Photos"); 84 86 assert_eq!(entries[0].entry_count, 0); 85 - assert_eq!(entries[1].name, "Documents"); 86 87 assert_eq!(entries[1].entry_count, 1); 87 88 } 88 89 ··· 102 103 let entries = list_directories(&mut client).await.unwrap(); 103 104 104 105 assert_eq!(entries.len(), 2); 105 - assert_eq!(entries[0].name, "First"); 106 - assert_eq!(entries[1].name, "Second"); 107 106 108 107 let reqs = mock.requests(); 109 108 assert!(reqs[1].url.contains("cursor=cursor-1"));
+40 -3
crates/opake-core/src/directories/mod.rs
··· 29 29 #[cfg(test)] 30 30 pub(crate) mod tests { 31 31 use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 32 - use crate::records::Directory; 32 + use crate::crypto::{self, DirectoryMetadata, OsRng}; 33 + use crate::records::{AtBytes, DirectEncryption, Directory, Encryption, EncryptionEnvelope}; 33 34 use crate::test_utils::MockTransport; 34 35 35 36 use super::*; 36 37 37 38 pub const TEST_DID: &str = "did:plc:test"; 38 39 40 + /// A fixed keypair for deterministic test encryption. 41 + /// Always returns the same key pair so `decrypt_names()` can unwrap 42 + /// any directory produced by `dummy_directory()`. 43 + pub fn test_keypair() -> (crypto::X25519PublicKey, crypto::X25519PrivateKey) { 44 + const SEED: [u8; 32] = [42u8; 32]; 45 + let secret = crypto::X25519DalekStaticSecret::from(SEED); 46 + let public = crypto::X25519DalekPublicKey::from(&secret); 47 + (*public.as_bytes(), secret.to_bytes()) 48 + } 49 + 50 + /// Build a dummy encrypted directory for tests. 51 + fn encrypt_dummy_directory(name: &str) -> (Encryption, crate::records::EncryptedMetadata) { 52 + let (pubkey, _) = test_keypair(); 53 + let content_key = crypto::generate_content_key(&mut OsRng); 54 + let metadata = DirectoryMetadata { 55 + name: name.into(), 56 + description: None, 57 + }; 58 + let encrypted_metadata = 59 + crypto::encrypt_metadata(&content_key, &metadata, &mut OsRng).unwrap(); 60 + let wrapped_key = crypto::wrap_key(&content_key, &pubkey, TEST_DID, &mut OsRng).unwrap(); 61 + let encryption = Encryption::Direct(DirectEncryption { 62 + envelope: EncryptionEnvelope { 63 + algo: "aes-256-gcm".into(), 64 + nonce: AtBytes::from_raw(&[0u8; 12]), 65 + keys: vec![wrapped_key], 66 + }, 67 + }); 68 + (encryption, encrypted_metadata) 69 + } 70 + 39 71 pub fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 40 72 let session = Session::Legacy(LegacySession { 41 73 did: TEST_DID.into(), ··· 47 79 } 48 80 49 81 pub fn dummy_directory(name: &str) -> Directory { 50 - Directory::new(name.into(), "2026-03-01T00:00:00Z".into()) 82 + let (encryption, encrypted_metadata) = encrypt_dummy_directory(name); 83 + Directory::new( 84 + encryption, 85 + encrypted_metadata, 86 + "2026-03-01T00:00:00Z".into(), 87 + ) 51 88 } 52 89 53 90 pub fn dummy_directory_with_entries(name: &str, entries: Vec<String>) -> Directory { 54 91 Directory { 55 92 entries, 56 - ..Directory::new(name.into(), "2026-03-01T00:00:00Z".into()) 93 + ..dummy_directory(name) 57 94 } 58 95 } 59 96
+10 -4
crates/opake-core/src/directories/remove_tests.rs
··· 4 4 5 5 use super::super::tests::{ 6 6 dummy_directory_with_entries, get_record_response, list_records_response, mock_client, 7 - put_record_response, 7 + put_record_response, test_keypair, TEST_DID, 8 8 }; 9 9 10 10 const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; ··· 59 59 )); 60 60 61 61 let mut client = mock_client(mock.clone()); 62 - let tree = DirectoryTree::load(&mut client).await.unwrap(); 62 + let mut tree = DirectoryTree::load(&mut client).await.unwrap(); 63 + let (_, private_key) = test_keypair(); 64 + tree.decrypt_names(TEST_DID, &private_key); 63 65 (client, tree) 64 66 } 65 67 ··· 89 91 )); 90 92 91 93 let mut client = mock_client(mock.clone()); 92 - let tree = DirectoryTree::load(&mut client).await.unwrap(); 94 + let mut tree = DirectoryTree::load(&mut client).await.unwrap(); 95 + let (_, private_key) = test_keypair(); 96 + tree.decrypt_names(TEST_DID, &private_key); 93 97 (client, tree) 94 98 } 95 99 ··· 164 168 )); 165 169 166 170 let mut client = mock_client(mock.clone()); 167 - let tree = DirectoryTree::load(&mut client).await.unwrap(); 171 + let mut tree = DirectoryTree::load(&mut client).await.unwrap(); 172 + let (_, private_key) = test_keypair(); 173 + tree.decrypt_names(TEST_DID, &private_key); 168 174 169 175 let resolved = tree.resolve(&mut client, "Empty").await.unwrap(); 170 176
+52 -2
crates/opake-core/src/directories/tree.rs
··· 14 14 15 15 use crate::atproto; 16 16 use crate::client::{list_collection, Transport, XrpcClient}; 17 + use crate::crypto::{self, DirectoryMetadata, X25519PrivateKey}; 17 18 use crate::documents::DOCUMENT_COLLECTION; 18 19 use crate::error::Error; 19 - use crate::records::{self, Directory, Document}; 20 + use crate::records::{self, Directory, Document, EncryptedMetadata, Encryption}; 20 21 21 22 use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_NAME, ROOT_DIRECTORY_RKEY}; 22 23 ··· 38 39 39 40 #[derive(Debug)] 40 41 struct DirectoryInfo { 42 + /// Decrypted name. Empty until `decrypt_names()` is called. 41 43 name: String, 44 + encryption: Encryption, 45 + encrypted_metadata: EncryptedMetadata, 42 46 entries: Vec<String>, 43 47 } 44 48 ··· 102 106 ( 103 107 uri.to_owned(), 104 108 DirectoryInfo { 105 - name: dir.name, 109 + name: String::new(), 110 + encryption: dir.encryption, 111 + encrypted_metadata: dir.encrypted_metadata, 106 112 entries: dir.entries, 107 113 }, 108 114 ) ··· 131 137 directories, 132 138 root_uri, 133 139 }) 140 + } 141 + 142 + /// Decrypt all directory names in-place. 143 + /// 144 + /// Unwraps each directory's content key from the encryption envelope, 145 + /// then decrypts the metadata to recover the real name. Directories 146 + /// whose keys can't be unwrapped (wrong DID, keyring not available) 147 + /// get a fallback name of "?". 148 + pub fn decrypt_names(&mut self, did: &str, private_key: &X25519PrivateKey) { 149 + for info in self.directories.values_mut() { 150 + let content_key = match &info.encryption { 151 + Encryption::Direct(direct) => { 152 + let wrapped = direct.envelope.keys.iter().find(|k| k.did == did); 153 + match wrapped { 154 + Some(w) => crypto::unwrap_key(w, private_key).ok(), 155 + None => None, 156 + } 157 + } 158 + Encryption::Keyring(_) => { 159 + // Keyring-encrypted directories require a group key, 160 + // which isn't available here. Future: accept optional 161 + // group key map (#191). 162 + None 163 + } 164 + }; 165 + 166 + if let Some(key) = content_key { 167 + if let Ok(metadata) = 168 + crypto::decrypt_metadata::<DirectoryMetadata>(&key, &info.encrypted_metadata) 169 + { 170 + info.name = metadata.name; 171 + continue; 172 + } 173 + } 174 + 175 + info.name = "?".into(); 176 + } 177 + 178 + // The root directory is always named "/". 179 + if let Some(root_uri) = &self.root_uri { 180 + if let Some(info) = self.directories.get_mut(root_uri) { 181 + info.name = ROOT_DIRECTORY_NAME.into(); 182 + } 183 + } 134 184 } 135 185 136 186 /// Resolve a user-provided reference to an AT-URI with metadata.
+15 -4
crates/opake-core/src/directories/tree_tests.rs
··· 2 2 use crate::client::HttpResponse; 3 3 use crate::test_utils::MockTransport; 4 4 5 - use super::super::tests::{dummy_directory_with_entries, list_records_response, mock_client}; 5 + use super::super::tests::{ 6 + dummy_directory_with_entries, list_records_response, mock_client, test_keypair, TEST_DID, 7 + }; 6 8 7 9 const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 8 10 const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.directory/photos"; ··· 50 52 )); 51 53 52 54 let mut client = mock_client(mock.clone()); 53 - DirectoryTree::load(&mut client).await.unwrap() 55 + let mut tree = DirectoryTree::load(&mut client).await.unwrap(); 56 + let (_, private_key) = test_keypair(); 57 + tree.decrypt_names(TEST_DID, &private_key); 58 + tree 54 59 } 55 60 56 61 /// Load a nested tree: / → Photos → [Vacation → [sunset.jpg], beach.jpg] ··· 77 82 )); 78 83 79 84 let mut client = mock_client(mock.clone()); 80 - DirectoryTree::load(&mut client).await.unwrap() 85 + let mut tree = DirectoryTree::load(&mut client).await.unwrap(); 86 + let (_, private_key) = test_keypair(); 87 + tree.decrypt_names(TEST_DID, &private_key); 88 + tree 81 89 } 82 90 83 91 // -- load -- ··· 319 327 )); 320 328 321 329 let mut client = mock_client(mock.clone()); 322 - let tree = DirectoryTree::load(&mut client).await.unwrap(); 330 + let mut tree = DirectoryTree::load(&mut client).await.unwrap(); 323 331 assert!(tree.root_uri.is_none()); 332 + 333 + let (_, private_key) = test_keypair(); 334 + tree.decrypt_names(TEST_DID, &private_key); 324 335 325 336 let mut client = mock_client(mock); 326 337 let resolved = tree.resolve(&mut client, "Photos").await.unwrap();
+11 -4
crates/opake-core/src/records/directory.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 3 - use super::{default_version, SCHEMA_VERSION}; 3 + use super::{default_version, EncryptedMetadata, SCHEMA_VERSION}; 4 + use crate::records::document::Encryption; 4 5 5 6 #[derive(Debug, Clone, Serialize, Deserialize)] 6 7 #[serde(rename_all = "camelCase")] 7 8 pub struct Directory { 8 9 #[serde(default = "default_version")] 9 10 pub opake_version: u32, 10 - pub name: String, 11 + pub encryption: Encryption, 12 + pub encrypted_metadata: EncryptedMetadata, 11 13 #[serde(default, skip_serializing_if = "Vec::is_empty")] 12 14 pub entries: Vec<String>, 13 15 pub created_at: String, ··· 16 18 } 17 19 18 20 impl Directory { 19 - pub fn new(name: String, created_at: String) -> Self { 21 + pub fn new( 22 + encryption: Encryption, 23 + encrypted_metadata: EncryptedMetadata, 24 + created_at: String, 25 + ) -> Self { 20 26 Self { 21 27 opake_version: SCHEMA_VERSION, 22 - name, 28 + encryption, 29 + encrypted_metadata, 23 30 entries: Vec::new(), 24 31 created_at, 25 32 modified_at: None,
+30 -4
crates/opake-core/src/records/mod.rs
··· 125 125 assert!(json["publicKey"]["$bytes"].is_string()); 126 126 } 127 127 128 + fn dummy_encrypted_directory(created_at: &str) -> Directory { 129 + let encryption = Encryption::Direct(DirectEncryption { 130 + envelope: EncryptionEnvelope { 131 + algo: "aes-256-gcm".into(), 132 + nonce: AtBytes::from_raw(&[0u8; 12]), 133 + keys: vec![WrappedKey { 134 + did: "did:plc:test".into(), 135 + ciphertext: AtBytes { 136 + encoded: "AAAA".into(), 137 + }, 138 + algo: "x25519-hkdf-a256kw".into(), 139 + }], 140 + }, 141 + }); 142 + let encrypted_metadata = EncryptedMetadata { 143 + ciphertext: AtBytes { 144 + encoded: "BBBB".into(), 145 + }, 146 + nonce: AtBytes { 147 + encoded: "CCCC".into(), 148 + }, 149 + }; 150 + Directory::new(encryption, encrypted_metadata, created_at.into()) 151 + } 152 + 128 153 #[test] 129 154 fn directory_roundtrips_through_json() { 130 - let directory = Directory::new("/".into(), "2026-03-01T00:00:00Z".into()); 155 + let directory = dummy_encrypted_directory("2026-03-01T00:00:00Z"); 131 156 let json = serde_json::to_string(&directory).unwrap(); 132 157 let parsed: Directory = serde_json::from_str(&json).unwrap(); 133 158 134 159 assert_eq!(parsed.opake_version, SCHEMA_VERSION); 135 - assert_eq!(parsed.name, "/"); 136 160 assert!(parsed.entries.is_empty()); 137 161 assert_eq!(parsed.created_at, "2026-03-01T00:00:00Z"); 138 162 assert!(parsed.modified_at.is_none()); 163 + // Encryption envelope is present 164 + assert!(matches!(parsed.encryption, Encryption::Direct(_))); 139 165 } 140 166 141 167 #[test] 142 168 fn directory_entries_omitted_when_empty() { 143 - let directory = Directory::new("Photos".into(), "2026-03-01T00:00:00Z".into()); 169 + let directory = dummy_encrypted_directory("2026-03-01T00:00:00Z"); 144 170 let json = serde_json::to_value(&directory).unwrap(); 145 171 assert!( 146 172 json.get("entries").is_none(), ··· 150 176 151 177 #[test] 152 178 fn directory_with_entries_roundtrips() { 153 - let mut directory = Directory::new("Photos".into(), "2026-03-01T00:00:00Z".into()); 179 + let mut directory = dummy_encrypted_directory("2026-03-01T00:00:00Z"); 154 180 directory.entries = vec![ 155 181 "at://did:plc:test/app.opake.document/abc".into(), 156 182 "at://did:plc:test/app.opake.directory/def".into(),
+51 -2
crates/opake-wasm/src/lib.rs
··· 1 1 use opake_core::client::dpop::DpopKeyPair; 2 2 use opake_core::client::oauth_discovery::generate_pkce; 3 3 use opake_core::crypto::{ 4 - ContentKey, DocumentMetadata, EncryptedPayload, GrantMetadata, KeyringMetadata, OsRng, 5 - X25519PrivateKey, X25519PublicKey, 4 + ContentKey, DirectoryMetadata, DocumentMetadata, EncryptedPayload, GrantMetadata, 5 + KeyringMetadata, OsRng, X25519PrivateKey, X25519PublicKey, 6 6 }; 7 7 use opake_core::records::WrappedKey; 8 8 use opake_core::storage::Identity; ··· 345 345 .map_err(|e| JsError::new(&e.to_string()))?; 346 346 serde_wasm_bindgen::to_value(&metadata).map_err(|e| JsError::new(&e.to_string())) 347 347 } 348 + 349 + // --------------------------------------------------------------------------- 350 + // Directory metadata encryption exports 351 + // --------------------------------------------------------------------------- 352 + 353 + /// Encrypt directory metadata (name, description) with a content key. 354 + /// 355 + /// `metadata` must be a JS object with fields: name (string), description? (string). 356 + /// Returns `{ ciphertext: Uint8Array, nonce: Uint8Array }`. 357 + #[wasm_bindgen(js_name = encryptDirectoryMetadata)] 358 + pub fn encrypt_directory_metadata_js(key: &[u8], metadata: JsValue) -> Result<JsValue, JsError> { 359 + let content_key = content_key_from_slice(key)?; 360 + let metadata: DirectoryMetadata = 361 + serde_wasm_bindgen::from_value(metadata).map_err(|e| JsError::new(&e.to_string()))?; 362 + let encrypted = opake_core::crypto::encrypt_metadata(&content_key, &metadata, &mut OsRng) 363 + .map_err(|e| JsError::new(&e.to_string()))?; 364 + 365 + let ciphertext = encrypted 366 + .ciphertext 367 + .decode() 368 + .map_err(|e| JsError::new(&e.to_string()))?; 369 + let nonce = encrypted 370 + .nonce 371 + .decode() 372 + .map_err(|e| JsError::new(&e.to_string()))?; 373 + 374 + let dto = EncryptedPayloadDto { ciphertext, nonce }; 375 + serde_wasm_bindgen::to_value(&dto).map_err(|e| JsError::new(&e.to_string())) 376 + } 377 + 378 + /// Decrypt directory metadata back to a JS object. 379 + /// 380 + /// Returns `{ name: string, description?: string }`. 381 + #[wasm_bindgen(js_name = decryptDirectoryMetadata)] 382 + pub fn decrypt_directory_metadata_js( 383 + key: &[u8], 384 + ciphertext: &[u8], 385 + nonce: &[u8], 386 + ) -> Result<JsValue, JsError> { 387 + let content_key = content_key_from_slice(key)?; 388 + let encrypted = opake_core::records::EncryptedMetadata { 389 + ciphertext: opake_core::records::AtBytes::from_raw(ciphertext), 390 + nonce: opake_core::records::AtBytes::from_raw(nonce), 391 + }; 392 + let metadata: DirectoryMetadata = 393 + opake_core::crypto::decrypt_metadata(&content_key, &encrypted) 394 + .map_err(|e| JsError::new(&e.to_string()))?; 395 + serde_wasm_bindgen::to_value(&metadata).map_err(|e| JsError::new(&e.to_string())) 396 + }
+13 -6
lexicons/app.opake.directory.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A directory in the personal cloud. Contains an ordered list of AT-URIs referencing documents or other directories. The root directory is a singleton at rkey 'self'.", 7 + "description": "A directory in the personal cloud. Contains an ordered list of AT-URIs referencing documents or other directories. The root directory is a singleton at rkey 'self'. Directory name is encrypted in encryptedMetadata.", 8 8 "key": "any", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["opakeVersion", "name", "createdAt"], 11 + "required": ["opakeVersion", "encryption", "encryptedMetadata", "createdAt"], 12 12 "properties": { 13 13 "opakeVersion": { 14 14 "type": "integer", 15 15 "description": "Schema version for the app.opake.* namespace.", 16 16 "minimum": 1 17 17 }, 18 - "name": { 19 - "type": "string", 20 - "description": "Human-readable directory name. '/' for root.", 21 - "maxLength": 512 18 + "encryption": { 19 + "type": "union", 20 + "description": "How to decrypt the directory metadata. Either a full encryption envelope (with per-directory wrapped keys) or a keyring reference (for group-based access).", 21 + "refs": [ 22 + "app.opake.document#directEncryption", 23 + "app.opake.document#keyringEncryption" 24 + ] 25 + }, 26 + "encryptedMetadata": { 27 + "type": "ref", 28 + "ref": "app.opake.defs#encryptedMetadata" 22 29 }, 23 30 "entries": { 24 31 "type": "array",