An encrypted personal cloud built on the AT Protocol.

Encrypt all document metadata with AES-256-GCM

Document metadata (name, mimeType, size, tags, description) is now
always encrypted using the same content key as the blob. Plaintext
record fields are set to dummy values; real metadata lives exclusively
in the mandatory encryptedMetadata field.

- Add encryptedMetadata type to lexicon defs, required on document
- Add crypto/metadata module: DocumentMetadata, encrypt/decrypt
- Add metadata/ module in core: read (fetch+decrypt), write (mutate+reencrypt+put)
- Upload always encrypts metadata, tags param removed, description added
- All download paths decrypt metadata for filename resolution
- CLI ls/tree decrypt metadata for display via shared document_resolve
- CLI download/rm use document_resolve for name-based lookups
- Rename mv command to move (move-only, rename deferred to #190)
- Add WASM encryptMetadata/decryptMetadata exports
- Add AtBytes::from_raw constructor

+1227 -670
+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 document metadata (name, mimeType, tags, description) [#187](https://issues.opake.app/issues/187.html) 69 70 - Rename collection NSIDs from app.opake.cloud.* to app.opake.* and version to opakeVersion [#186](https://issues.opake.app/issues/186.html) 70 71 - Add browser key storage with IndexedDB and Web Crypto API [#160](https://issues.opake.app/issues/160.html) 71 72 - Add inbox command for grant discovery via AppView [#162](https://issues.opake.app/issues/162.html)
+9 -1
crates/opake-cli/src/commands/download.rs
··· 9 9 use opake_core::client::Session; 10 10 11 11 use crate::commands::Execute; 12 + use crate::document_resolve; 12 13 use crate::identity; 13 14 use crate::keyring_store; 14 15 use crate::session::{self, CommandContext}; ··· 89 90 .as_deref() 90 91 .ok_or_else(|| anyhow::anyhow!("provide a document reference or --grant"))?; 91 92 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 92 - let uri = documents::resolve_uri(&mut client, reference).await?; 93 + let uri = document_resolve::resolve_uri( 94 + &mut client, 95 + reference, 96 + &id.did, 97 + &private_key, 98 + &ctx.storage, 99 + ) 100 + .await?; 93 101 94 102 // Peek at the document to check if it uses keyring encryption. 95 103 // If so, load the local group key before attempting decryption.
+42
crates/opake-cli/src/commands/ls.rs
··· 5 5 use opake_core::client::Session; 6 6 7 7 use crate::commands::Execute; 8 + use crate::document_resolve; 9 + use crate::identity; 8 10 use crate::session::{self, CommandContext}; 9 11 10 12 #[derive(Args)] ··· 76 78 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 77 79 let mut entries = documents::list_documents(&mut client).await?; 78 80 81 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 82 + let private_key = id.private_key_bytes()?; 83 + for entry in &mut entries { 84 + document_resolve::decrypt_entry_in_place(entry, &ctx.did, &private_key, &ctx.storage); 85 + } 86 + 79 87 if let Some(ref tag) = self.tag { 80 88 filter_by_tag(&mut entries, tag); 81 89 } ··· 104 112 #[cfg(test)] 105 113 mod tests { 106 114 use super::*; 115 + use opake_core::records::{ 116 + AtBytes, DirectEncryption, EncryptedMetadata, Encryption, EncryptionEnvelope, WrappedKey, 117 + }; 118 + 119 + fn dummy_encrypted_metadata() -> EncryptedMetadata { 120 + EncryptedMetadata { 121 + ciphertext: AtBytes { 122 + encoded: "AAAA".into(), 123 + }, 124 + nonce: AtBytes { 125 + encoded: "AAAAAAAAAAAAAAAA".into(), 126 + }, 127 + } 128 + } 129 + 130 + fn dummy_encryption() -> Encryption { 131 + Encryption::Direct(DirectEncryption { 132 + envelope: EncryptionEnvelope { 133 + algo: "aes-256-gcm".into(), 134 + nonce: AtBytes { 135 + encoded: "AAAAAAAAAAAAAAAA".into(), 136 + }, 137 + keys: vec![WrappedKey { 138 + did: "did:plc:test".into(), 139 + ciphertext: AtBytes { 140 + encoded: "AAAA".into(), 141 + }, 142 + algo: "x25519-hkdf-a256kw".into(), 143 + }], 144 + }, 145 + }) 146 + } 107 147 108 148 fn entry(name: &str, uri: &str, tags: Vec<String>) -> DocumentEntry { 109 149 DocumentEntry { ··· 113 153 mime_type: Some("text/plain".into()), 114 154 tags, 115 155 created_at: "2026-03-01T00:00:00Z".into(), 156 + encrypted_metadata: dummy_encrypted_metadata(), 157 + encryption: dummy_encryption(), 116 158 } 117 159 } 118 160
+1 -1
crates/opake-cli/src/commands/mod.rs
··· 7 7 pub mod logout; 8 8 pub mod ls; 9 9 pub mod mkdir; 10 - pub mod mv; 10 + pub mod move_cmd; 11 11 pub mod pair; 12 12 pub mod resolve; 13 13 pub mod revoke;
+43
crates/opake-cli/src/commands/move_cmd.rs
··· 1 + use anyhow::Result; 2 + use chrono::Utc; 3 + use clap::Args; 4 + use opake_core::client::Session; 5 + use opake_core::directories::{check_cycle, move_entry, DirectoryTree, EntryKind}; 6 + 7 + use crate::commands::Execute; 8 + use crate::session::{self, CommandContext}; 9 + 10 + #[derive(Args)] 11 + /// Move a document or directory into another directory 12 + pub struct MoveCommand { 13 + /// Source path, filename, or AT-URI 14 + source: String, 15 + 16 + /// Target directory path or AT-URI (must end with / or resolve to a directory) 17 + destination: String, 18 + } 19 + 20 + impl Execute for MoveCommand { 21 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 22 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 23 + let now = Utc::now().to_rfc3339(); 24 + 25 + let tree = DirectoryTree::load(&mut client).await?; 26 + let source = tree.resolve(&mut client, &self.source).await?; 27 + let dest = tree.resolve(&mut client, &self.destination).await?; 28 + 29 + if dest.kind != EntryKind::Directory { 30 + anyhow::bail!("{:?} is not a directory", self.destination); 31 + } 32 + 33 + if source.kind == EntryKind::Directory { 34 + check_cycle(&tree, &source.uri, &dest.uri)?; 35 + } 36 + 37 + move_entry(&mut client, &source, &dest.uri, &now).await?; 38 + 39 + println!("moved {:?} → {}", source.name, self.destination); 40 + 41 + Ok(session::refreshed_session(&client)) 42 + } 43 + }
-96
crates/opake-cli/src/commands/mv.rs
··· 1 - use anyhow::Result; 2 - use chrono::Utc; 3 - use clap::Args; 4 - use opake_core::client::Session; 5 - use opake_core::directories::{ 6 - self, check_cycle, move_entry, DirectoryTree, EntryKind, MoveDestination, 7 - }; 8 - use opake_core::error::Error as CoreError; 9 - 10 - use crate::commands::Execute; 11 - use crate::session::{self, CommandContext}; 12 - 13 - #[derive(Args)] 14 - /// Move or rename a document or directory 15 - pub struct MvCommand { 16 - /// Source path, filename, or AT-URI 17 - source: String, 18 - 19 - /// Destination path or new name 20 - destination: String, 21 - } 22 - 23 - impl Execute for MvCommand { 24 - async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 25 - let mut client = session::load_client(&ctx.storage, &ctx.did)?; 26 - let now = Utc::now().to_rfc3339(); 27 - 28 - let tree = DirectoryTree::load(&mut client).await?; 29 - let source = tree.resolve(&mut client, &self.source).await?; 30 - 31 - let destination = 32 - resolve_destination(&tree, &mut client, &self.destination, &source).await?; 33 - 34 - // Cycle guard for directory moves. 35 - if source.kind == EntryKind::Directory { 36 - if let MoveDestination::IntoDirectory { ref directory_uri } = destination { 37 - check_cycle(&tree, &source.uri, directory_uri)?; 38 - } 39 - } 40 - 41 - let result = move_entry(&mut client, &tree, &source, &destination, &now).await?; 42 - 43 - match (&result.new_name, &destination) { 44 - (Some(new_name), _) => println!("renamed {:?} → {:?}", source.name, new_name), 45 - (None, MoveDestination::IntoDirectory { .. }) => { 46 - println!("moved {:?} → {}", source.name, self.destination) 47 - } 48 - _ => println!("moved {}", result.uri), 49 - } 50 - 51 - Ok(session::refreshed_session(&client)) 52 - } 53 - } 54 - 55 - /// Interpret the destination argument. 56 - /// 57 - /// - Trailing `/` → must resolve to an existing directory 58 - /// - Resolves to an existing directory → move into it 59 - /// - Otherwise → rename (new name = last path segment or bare name) 60 - async fn resolve_destination( 61 - tree: &DirectoryTree, 62 - client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 63 - destination: &str, 64 - _source: &directories::ResolvedPath, 65 - ) -> Result<MoveDestination, CoreError> { 66 - let explicit_directory = destination.ends_with('/'); 67 - let trimmed = destination.trim_end_matches('/'); 68 - 69 - // Try resolving as path/name in the tree. 70 - match tree.resolve(client, trimmed).await { 71 - Ok(resolved) if resolved.kind == EntryKind::Directory => { 72 - Ok(MoveDestination::IntoDirectory { 73 - directory_uri: resolved.uri, 74 - }) 75 - } 76 - Ok(_) if explicit_directory => Err(CoreError::NotFound(format!( 77 - "{trimmed:?} is not a directory" 78 - ))), 79 - Ok(_) => { 80 - // Resolved to a document — that's a naming conflict. 81 - Err(CoreError::InvalidRecord(format!( 82 - "a document named {trimmed:?} already exists" 83 - ))) 84 - } 85 - Err(CoreError::NotFound(_)) if explicit_directory => Err(CoreError::NotFound(format!( 86 - "directory not found: {trimmed:?}" 87 - ))), 88 - Err(CoreError::NotFound(_)) => { 89 - // Doesn't exist — treat as a rename. 90 - Ok(MoveDestination::Rename { 91 - new_name: trimmed.to_string(), 92 - }) 93 - } 94 - Err(e) => Err(e), 95 - } 96 - }
+18 -3
crates/opake-cli/src/commands/rm.rs
··· 7 7 use opake_core::{atproto, documents}; 8 8 9 9 use crate::commands::Execute; 10 + use crate::document_resolve; 11 + use crate::identity; 10 12 use crate::session::{self, CommandContext}; 11 13 12 14 #[derive(Args)] ··· 40 42 async fn try_fast_resolve( 41 43 client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 42 44 reference: &str, 45 + did: &str, 46 + private_key: &opake_core::crypto::X25519PrivateKey, 47 + storage: &crate::config::FileStorage, 43 48 ) -> Result<Resolution, CoreError> { 44 49 // Paths always need the tree. 45 50 if reference.contains('/') { ··· 61 66 })); 62 67 } 63 68 64 - // Bare name — try document-only resolution (1 paginated API call). 65 - match documents::resolve_uri(client, reference).await { 69 + // Bare name — try document-only resolution with metadata decryption. 70 + match document_resolve::resolve_uri(client, reference, did, private_key, storage).await { 66 71 Ok(uri) => Ok(Resolution::Fast(ResolvedPath { 67 72 uri, 68 73 kind: EntryKind::Document, ··· 80 85 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 81 86 let now = Utc::now().to_rfc3339(); 82 87 83 - let resolution = try_fast_resolve(&mut client, &self.reference).await?; 88 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 89 + let private_key = id.private_key_bytes()?; 90 + 91 + let resolution = try_fast_resolve( 92 + &mut client, 93 + &self.reference, 94 + &ctx.did, 95 + &private_key, 96 + &ctx.storage, 97 + ) 98 + .await?; 84 99 85 100 // Fast path: document by bare name or AT-URI, no tree needed. 86 101 if let Resolution::Fast(resolved) = resolution {
+19 -1
crates/opake-cli/src/commands/tree.rs
··· 1 + use std::collections::HashMap; 2 + 1 3 use anyhow::Result; 2 4 use clap::Args; 3 5 use opake_core::client::Session; 4 6 use opake_core::directories::DirectoryTree; 7 + use opake_core::documents; 5 8 6 9 use crate::commands::Execute; 10 + use crate::document_resolve; 11 + use crate::identity; 7 12 use crate::session::{self, CommandContext}; 8 13 9 14 #[derive(Args)] ··· 13 18 impl Execute for TreeCommand { 14 19 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 15 20 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 16 - let (tree, documents) = DirectoryTree::load_full(&mut client).await?; 21 + let tree = DirectoryTree::load(&mut client).await?; 22 + 23 + let entries = documents::list_documents(&mut client).await?; 24 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 25 + let private_key = id.private_key_bytes()?; 26 + 27 + let documents: HashMap<String, String> = entries 28 + .iter() 29 + .map(|e| { 30 + let name = 31 + document_resolve::decrypt_entry_name(e, &ctx.did, &private_key, &ctx.storage); 32 + (e.uri.clone(), name) 33 + }) 34 + .collect(); 17 35 18 36 println!("{}", tree.render(&documents)); 19 37
+6 -7
crates/opake-cli/src/commands/upload.rs
··· 27 27 #[arg(long)] 28 28 keyring: Option<String>, 29 29 30 - /// Comma-separated tags for categorization 31 - #[arg(long, value_delimiter = ',')] 32 - tags: Vec<String>, 30 + /// Optional description for the document 31 + #[arg(long)] 32 + description: Option<String>, 33 33 34 34 /// Place the uploaded document into a directory 35 35 #[arg(long)] ··· 72 72 keyring_uri: &entry.uri, 73 73 group_key: &group_key, 74 74 rotation: entry.rotation, 75 - tags: self.tags, 75 + description: self.description.as_deref(), 76 76 created_at: &now, 77 77 }; 78 78 ··· 87 87 mime_type, 88 88 owner_did: &id.did, 89 89 owner_pubkey: &owner_pubkey, 90 - tags: self.tags, 90 + description: self.description.as_deref(), 91 91 created_at: &now, 92 92 }; 93 93 ··· 115 115 #[cfg(test)] 116 116 mod tests { 117 117 use super::*; 118 - use crate::config::FileStorage; 119 118 use crate::utils::test_harness::test_storage; 120 119 121 120 #[test] ··· 125 124 let cmd = UploadCommand { 126 125 path: PathBuf::from("/tmp/opake-test-nonexistent-file-abc123"), 127 126 keyring: None, 128 - tags: vec![], 127 + description: None, 129 128 dir: None, 130 129 }; 131 130 let ctx = CommandContext {
+151
crates/opake-cli/src/document_resolve.rs
··· 1 + // Name-based document resolution and metadata decryption for the CLI. 2 + // 3 + // Provides shared helpers for all CLI commands that need to read 4 + // document names from encrypted metadata: ls, tree, download, rm, mv. 5 + 6 + use log::warn; 7 + use opake_core::client::{Transport, XrpcClient}; 8 + use opake_core::crypto::{self, X25519PrivateKey}; 9 + use opake_core::documents::{self, DocumentEntry}; 10 + use opake_core::error::Error; 11 + use opake_core::records::Encryption; 12 + 13 + use crate::config::FileStorage; 14 + use crate::keyring_store; 15 + 16 + /// Resolve a user-provided reference to an AT-URI, decrypting metadata 17 + /// to match document names. 18 + /// 19 + /// If `reference` is already an `at://` URI, it's returned as-is. 20 + /// Otherwise, lists all documents, decrypts their metadata, and matches 21 + /// by name. 22 + pub async fn resolve_uri( 23 + client: &mut XrpcClient<impl Transport>, 24 + reference: &str, 25 + did: &str, 26 + private_key: &X25519PrivateKey, 27 + storage: &FileStorage, 28 + ) -> Result<String, Error> { 29 + if reference.starts_with("at://") { 30 + return Ok(reference.to_string()); 31 + } 32 + 33 + let entries = documents::list_documents(client).await?; 34 + let matches: Vec<(&DocumentEntry, String)> = entries 35 + .iter() 36 + .filter_map(|e| { 37 + let name = decrypt_entry_name(e, did, private_key, storage); 38 + if name == reference { 39 + Some((e, name)) 40 + } else { 41 + None 42 + } 43 + }) 44 + .collect(); 45 + 46 + match matches.len() { 47 + 0 => Err(Error::NotFound(format!( 48 + "no document named {:?} — use `opake ls` to see your documents", 49 + reference 50 + ))), 51 + 1 => Ok(matches[0].0.uri.clone()), 52 + n => { 53 + let uris: Vec<String> = matches.iter().map(|(e, _)| e.uri.clone()).collect(); 54 + Err(Error::AmbiguousName { 55 + name: reference.to_string(), 56 + count: n, 57 + uris, 58 + }) 59 + } 60 + } 61 + } 62 + 63 + /// Decrypt the name from a document entry's encrypted metadata. 64 + /// Falls back to the plaintext name field for old records or on failure. 65 + pub fn decrypt_entry_name( 66 + entry: &DocumentEntry, 67 + did: &str, 68 + private_key: &X25519PrivateKey, 69 + storage: &FileStorage, 70 + ) -> String { 71 + let content_key = match unwrap_entry_content_key(entry, did, private_key, storage) { 72 + Ok(key) => key, 73 + Err(e) => { 74 + warn!("could not unwrap key for {}: {e}", entry.uri); 75 + return entry.name.clone(); 76 + } 77 + }; 78 + 79 + match crypto::decrypt_metadata(&content_key, &entry.encrypted_metadata) { 80 + Ok(metadata) => metadata.name, 81 + Err(e) => { 82 + warn!("metadata decryption failed for {}: {e}", entry.uri); 83 + entry.name.clone() 84 + } 85 + } 86 + } 87 + 88 + /// Decrypt encrypted metadata on a document entry in place, replacing 89 + /// the dummy plaintext fields with real values. 90 + /// 91 + /// Silently falls back to plaintext fields if decryption fails. 92 + pub fn decrypt_entry_in_place( 93 + entry: &mut DocumentEntry, 94 + did: &str, 95 + private_key: &X25519PrivateKey, 96 + storage: &FileStorage, 97 + ) { 98 + let content_key = match unwrap_entry_content_key(entry, did, private_key, storage) { 99 + Ok(key) => key, 100 + Err(e) => { 101 + warn!("could not decrypt metadata for {}: {e}", entry.uri); 102 + return; 103 + } 104 + }; 105 + 106 + match crypto::decrypt_metadata(&content_key, &entry.encrypted_metadata) { 107 + Ok(metadata) => { 108 + entry.name = metadata.name; 109 + entry.mime_type = metadata.mime_type; 110 + entry.size = metadata.size; 111 + entry.tags = metadata.tags; 112 + } 113 + Err(e) => { 114 + warn!("metadata decryption failed for {}: {e}", entry.uri); 115 + } 116 + } 117 + } 118 + 119 + /// Unwrap the content key from an entry's encryption envelope. 120 + fn unwrap_entry_content_key( 121 + entry: &DocumentEntry, 122 + did: &str, 123 + private_key: &X25519PrivateKey, 124 + storage: &FileStorage, 125 + ) -> anyhow::Result<crypto::ContentKey> { 126 + match &entry.encryption { 127 + Encryption::Direct(direct) => { 128 + let wrapped = direct 129 + .envelope 130 + .keys 131 + .iter() 132 + .find(|k| k.did == did) 133 + .ok_or_else(|| anyhow::anyhow!("no wrapped key for your DID"))?; 134 + Ok(crypto::unwrap_key(wrapped, private_key)?) 135 + } 136 + Encryption::Keyring(kr_enc) => { 137 + let kr_uri = opake_core::atproto::parse_at_uri(&kr_enc.keyring_ref.keyring)?; 138 + let group_key = keyring_store::load_group_key( 139 + storage, 140 + did, 141 + &kr_uri.rkey, 142 + kr_enc.keyring_ref.rotation, 143 + )?; 144 + let wrapped_bytes = kr_enc.keyring_ref.wrapped_content_key.decode()?; 145 + Ok(crypto::unwrap_content_key_from_keyring( 146 + &wrapped_bytes, 147 + &group_key, 148 + )?) 149 + } 150 + } 151 + }
+4 -2
crates/opake-cli/src/main.rs
··· 1 1 mod commands; 2 2 mod config; 3 + mod document_resolve; 3 4 mod identity; 4 5 mod keyring_store; 5 6 mod oauth; ··· 42 43 Inbox(commands::inbox::InboxCommand), 43 44 Ls(commands::ls::LsCommand), 44 45 Mkdir(commands::mkdir::MkdirCommand), 45 - Mv(commands::mv::MvCommand), 46 + /// Moves a file to another directory. Use the metadata command for that. 47 + Move(commands::move_cmd::MoveCommand), 46 48 Rm(commands::rm::RmCommand), 47 49 Resolve(commands::resolve::ResolveCommand), 48 50 Share(commands::share::ShareCommand), ··· 109 111 Command::Inbox(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 110 112 Command::Ls(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 111 113 Command::Mkdir(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 112 - Command::Mv(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 114 + Command::Move(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 113 115 Command::Rm(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 114 116 Command::Resolve(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 115 117 Command::Share(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?,
+8
crates/opake-core/src/atproto.rs
··· 62 62 } 63 63 64 64 impl AtBytes { 65 + /// Construct from raw bytes, base64-encoding them for the wire format. 66 + pub fn from_raw(bytes: &[u8]) -> Self { 67 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 68 + Self { 69 + encoded: BASE64.encode(bytes), 70 + } 71 + } 72 + 65 73 /// Decode the base64 payload, accepting both padded and unpadded input. 66 74 /// 67 75 /// The PDS strips padding from `$bytes` fields during CBOR→JSON
+84
crates/opake-core/src/crypto/metadata.rs
··· 1 + use aes_gcm::{ 2 + aead::{Aead, AeadCore, KeyInit}, 3 + Aes256Gcm, Key, Nonce, 4 + }; 5 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + use super::{ContentKey, CryptoRng, RngCore}; 9 + use crate::error::Error; 10 + use crate::records::{AtBytes, EncryptedMetadata}; 11 + 12 + /// The plaintext metadata that gets encrypted inside `encryptedMetadata`. 13 + /// Serialized to JSON before encryption. 14 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 15 + #[serde(rename_all = "camelCase")] 16 + pub struct DocumentMetadata { 17 + pub name: String, 18 + #[serde(skip_serializing_if = "Option::is_none")] 19 + pub mime_type: Option<String>, 20 + #[serde(skip_serializing_if = "Option::is_none")] 21 + pub size: Option<u64>, 22 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 23 + pub tags: Vec<String>, 24 + #[serde(skip_serializing_if = "Option::is_none")] 25 + pub description: Option<String>, 26 + } 27 + 28 + /// Encrypt document metadata with the same content key used for the blob. 29 + /// 30 + /// Serializes the metadata to JSON, encrypts with AES-256-GCM using a fresh 31 + /// nonce, and returns the result as an `EncryptedMetadata` record field. 32 + pub fn encrypt_metadata( 33 + key: &ContentKey, 34 + metadata: &DocumentMetadata, 35 + rng: &mut (impl CryptoRng + RngCore), 36 + ) -> Result<EncryptedMetadata, Error> { 37 + let plaintext = serde_json::to_vec(metadata) 38 + .map_err(|e| Error::Encryption(format!("failed to serialize metadata: {e}")))?; 39 + 40 + let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key.0)); 41 + let nonce = Aes256Gcm::generate_nonce(rng); 42 + let ciphertext = cipher 43 + .encrypt(&nonce, plaintext.as_ref()) 44 + .map_err(|e| Error::Encryption(e.to_string()))?; 45 + 46 + Ok(EncryptedMetadata { 47 + ciphertext: AtBytes { 48 + encoded: BASE64.encode(&ciphertext), 49 + }, 50 + nonce: AtBytes { 51 + encoded: BASE64.encode(nonce.as_slice()), 52 + }, 53 + }) 54 + } 55 + 56 + /// Decrypt an `EncryptedMetadata` payload back to `DocumentMetadata`. 57 + pub fn decrypt_metadata( 58 + key: &ContentKey, 59 + encrypted: &EncryptedMetadata, 60 + ) -> Result<DocumentMetadata, Error> { 61 + let ciphertext = encrypted 62 + .ciphertext 63 + .decode() 64 + .map_err(|e| Error::Decryption(format!("invalid metadata ciphertext: {e}")))?; 65 + 66 + let nonce_bytes = encrypted 67 + .nonce 68 + .decode() 69 + .map_err(|e| Error::Decryption(format!("invalid metadata nonce: {e}")))?; 70 + 71 + let nonce = Nonce::from_slice(&nonce_bytes); 72 + 73 + let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key.0)); 74 + let plaintext = cipher 75 + .decrypt(nonce, ciphertext.as_ref()) 76 + .map_err(|e| Error::Decryption(e.to_string()))?; 77 + 78 + serde_json::from_slice(&plaintext) 79 + .map_err(|e| Error::Decryption(format!("invalid metadata JSON: {e}"))) 80 + } 81 + 82 + #[cfg(test)] 83 + #[path = "metadata_tests.rs"] 84 + mod tests;
+80
crates/opake-core/src/crypto/metadata_tests.rs
··· 1 + use super::*; 2 + use crate::crypto::{generate_content_key, OsRng}; 3 + 4 + fn sample_metadata() -> DocumentMetadata { 5 + DocumentMetadata { 6 + name: "tax-return-2025.pdf".into(), 7 + mime_type: Some("application/pdf".into()), 8 + size: Some(1_048_576), 9 + tags: vec!["finance".into(), "2025".into()], 10 + description: Some("Annual tax return".into()), 11 + } 12 + } 13 + 14 + #[test] 15 + fn roundtrip() { 16 + let key = generate_content_key(&mut OsRng); 17 + let metadata = sample_metadata(); 18 + 19 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 20 + let decrypted = decrypt_metadata(&key, &encrypted).unwrap(); 21 + 22 + assert_eq!(decrypted, metadata); 23 + } 24 + 25 + #[test] 26 + fn wrong_key_fails() { 27 + let key = generate_content_key(&mut OsRng); 28 + let wrong_key = generate_content_key(&mut OsRng); 29 + let metadata = sample_metadata(); 30 + 31 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 32 + let err = decrypt_metadata(&wrong_key, &encrypted).unwrap_err(); 33 + 34 + assert!( 35 + err.to_string().contains("aead"), 36 + "expected decryption error, got: {err}" 37 + ); 38 + } 39 + 40 + #[test] 41 + fn minimal_metadata() { 42 + let key = generate_content_key(&mut OsRng); 43 + let metadata = DocumentMetadata { 44 + name: "unnamed".into(), 45 + mime_type: None, 46 + size: None, 47 + tags: vec![], 48 + description: None, 49 + }; 50 + 51 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 52 + let decrypted = decrypt_metadata(&key, &encrypted).unwrap(); 53 + 54 + assert_eq!(decrypted.name, "unnamed"); 55 + assert!(decrypted.mime_type.is_none()); 56 + assert!(decrypted.size.is_none()); 57 + assert!(decrypted.tags.is_empty()); 58 + assert!(decrypted.description.is_none()); 59 + } 60 + 61 + #[test] 62 + fn different_nonces_per_encryption() { 63 + let key = generate_content_key(&mut OsRng); 64 + let metadata = sample_metadata(); 65 + 66 + let a = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 67 + let b = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 68 + 69 + assert_ne!(a.nonce.encoded, b.nonce.encoded); 70 + assert_ne!(a.ciphertext.encoded, b.ciphertext.encoded); 71 + } 72 + 73 + #[test] 74 + fn camel_case_serialization() { 75 + let metadata = sample_metadata(); 76 + let json = serde_json::to_value(&metadata).unwrap(); 77 + 78 + assert!(json.get("mimeType").is_some()); 79 + assert!(json.get("mime_type").is_none()); 80 + }
+2
crates/opake-core/src/crypto/mod.rs
··· 12 12 mod content; 13 13 mod key_wrapping; 14 14 mod keyring_wrapping; 15 + mod metadata; 15 16 16 17 use crate::records::SCHEMA_VERSION; 17 18 ··· 29 30 pub use content::{decrypt_blob, encrypt_blob, generate_content_key}; 30 31 pub use key_wrapping::{create_group_key, unwrap_key, wrap_key}; 31 32 pub use keyring_wrapping::{unwrap_content_key_from_keyring, wrap_content_key_for_keyring}; 33 + pub use metadata::{decrypt_metadata, encrypt_metadata, DocumentMetadata}; 32 34 33 35 const WRAP_ALGO: &str = "x25519-hkdf-a256kw"; 34 36 const CONTENT_KEY_LEN: usize = 32;
+2 -2
crates/opake-core/src/directories/mod.rs
··· 9 9 mod entries; 10 10 mod get_or_create_root; 11 11 mod list; 12 - mod mv; 12 + mod move_entry; 13 13 mod remove; 14 14 mod tree; 15 15 ··· 18 18 pub use entries::{add_entry, remove_entry}; 19 19 pub use get_or_create_root::get_or_create_root; 20 20 pub use list::{list_directories, DirectoryEntry}; 21 - pub use mv::{check_cycle, move_entry, MoveDestination, MoveResult}; 21 + pub use move_entry::{check_cycle, move_entry, MoveResult}; 22 22 pub use remove::{remove, RemoveResult}; 23 23 pub use tree::{DirectoryTree, EntryKind, ResolvedPath}; 24 24
+74
crates/opake-core/src/directories/move_entry.rs
··· 1 + // Move operations for documents and directories. 2 + // 3 + // Move = re-parent (remove from old directory, add to new directory). 4 + // Rename is handled by the metadata command (#190). 5 + 6 + use log::debug; 7 + 8 + use crate::client::{Transport, XrpcClient}; 9 + use crate::error::Error; 10 + 11 + use super::entries::{add_entry, remove_entry}; 12 + use super::tree::{DirectoryTree, EntryKind, ResolvedPath}; 13 + 14 + #[derive(Debug)] 15 + pub struct MoveResult { 16 + pub uri: String, 17 + } 18 + 19 + /// Move a document or directory into a target directory. 20 + pub async fn move_entry( 21 + client: &mut XrpcClient<impl Transport>, 22 + source: &ResolvedPath, 23 + target_dir_uri: &str, 24 + modified_at: &str, 25 + ) -> Result<MoveResult, Error> { 26 + // Remove from old parent if tracked. 27 + if let Some(parent_uri) = &source.parent_uri { 28 + if parent_uri == target_dir_uri { 29 + return Err(Error::InvalidRecord(format!( 30 + "{:?} is already in that directory", 31 + source.name, 32 + ))); 33 + } 34 + debug!("removing {} from old parent {}", source.uri, parent_uri); 35 + remove_entry(client, parent_uri, &source.uri, modified_at).await?; 36 + } 37 + 38 + debug!("adding {} to new parent {}", source.uri, target_dir_uri); 39 + add_entry(client, target_dir_uri, &source.uri, modified_at).await?; 40 + 41 + Ok(MoveResult { 42 + uri: source.uri.clone(), 43 + }) 44 + } 45 + 46 + /// Check that moving a directory into a target doesn't create a cycle. 47 + /// 48 + /// A directory can't be moved into itself or any of its descendants. 49 + pub fn check_cycle( 50 + tree: &DirectoryTree, 51 + source_uri: &str, 52 + target_dir_uri: &str, 53 + ) -> Result<(), Error> { 54 + if source_uri == target_dir_uri { 55 + return Err(Error::InvalidRecord( 56 + "cannot move a directory into itself".into(), 57 + )); 58 + } 59 + 60 + let descendants = tree.collect_descendants(source_uri); 61 + for (uri, kind) in &descendants { 62 + if kind == &EntryKind::Directory && uri == target_dir_uri { 63 + return Err(Error::InvalidRecord( 64 + "cannot move a directory into one of its descendants".into(), 65 + )); 66 + } 67 + } 68 + 69 + Ok(()) 70 + } 71 + 72 + #[cfg(test)] 73 + #[path = "move_entry_tests.rs"] 74 + mod tests;
+188
crates/opake-core/src/directories/move_entry_tests.rs
··· 1 + use super::*; 2 + use crate::client::{HttpResponse, RequestBody}; 3 + use crate::records::Directory; 4 + use crate::test_utils::MockTransport; 5 + 6 + use super::super::tests::{ 7 + dummy_directory, dummy_directory_with_entries, get_record_response, list_records_response, 8 + mock_client, put_record_response, 9 + }; 10 + 11 + const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 12 + const DIR_A_URI: &str = "at://did:plc:test/app.opake.directory/dirA"; 13 + const DIR_B_URI: &str = "at://did:plc:test/app.opake.directory/dirB"; 14 + const DOC_URI: &str = "at://did:plc:test/app.opake.document/doc1"; 15 + 16 + async fn load_tree_with(dirs: &[(&str, Directory)]) -> DirectoryTree { 17 + let mock = MockTransport::new(); 18 + mock.enqueue(list_records_response(dirs, None)); 19 + let mut client = mock_client(mock); 20 + DirectoryTree::load(&mut client).await.unwrap() 21 + } 22 + 23 + fn source_doc(parent_uri: Option<&str>) -> ResolvedPath { 24 + ResolvedPath { 25 + uri: DOC_URI.to_string(), 26 + kind: EntryKind::Document, 27 + name: "beach.jpg".to_string(), 28 + parent_uri: parent_uri.map(String::from), 29 + } 30 + } 31 + 32 + fn source_dir(uri: &str, name: &str, parent_uri: Option<&str>) -> ResolvedPath { 33 + ResolvedPath { 34 + uri: uri.to_string(), 35 + kind: EntryKind::Directory, 36 + name: name.to_string(), 37 + parent_uri: parent_uri.map(String::from), 38 + } 39 + } 40 + 41 + // -- move into directory -- 42 + 43 + #[tokio::test] 44 + async fn move_doc_into_directory() { 45 + let _tree = load_tree_with(&[ 46 + ( 47 + "self", 48 + dummy_directory_with_entries("/", vec![DOC_URI.into()]), 49 + ), 50 + ("dirA", dummy_directory("Photos")), 51 + ]) 52 + .await; 53 + 54 + let mock = MockTransport::new(); 55 + // remove_entry: get old parent, put old parent 56 + mock.enqueue(get_record_response( 57 + ROOT_URI, 58 + &dummy_directory_with_entries("/", vec![DOC_URI.into()]), 59 + )); 60 + mock.enqueue(put_record_response(ROOT_URI)); 61 + // add_entry: get new parent, put new parent 62 + mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 63 + mock.enqueue(put_record_response(DIR_A_URI)); 64 + 65 + let mut client = mock_client(mock.clone()); 66 + let source = source_doc(Some(ROOT_URI)); 67 + 68 + let result = move_entry(&mut client, &source, DIR_A_URI, "2026-03-01T12:00:00Z") 69 + .await 70 + .unwrap(); 71 + 72 + assert_eq!(result.uri, DOC_URI); 73 + 74 + let reqs = mock.requests(); 75 + assert_eq!(reqs.len(), 4); 76 + 77 + // Old parent should have doc removed 78 + match &reqs[1].body { 79 + Some(RequestBody::Json(v)) => { 80 + let dir: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 81 + assert!(dir.entries.is_empty()); 82 + } 83 + _ => panic!("expected JSON body"), 84 + } 85 + 86 + // New parent should have doc added 87 + match &reqs[3].body { 88 + Some(RequestBody::Json(v)) => { 89 + let dir: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 90 + assert_eq!(dir.entries, vec![DOC_URI]); 91 + } 92 + _ => panic!("expected JSON body"), 93 + } 94 + } 95 + 96 + #[tokio::test] 97 + async fn move_untracked_doc_into_directory() { 98 + let _tree = load_tree_with(&[ 99 + ("self", dummy_directory("/")), 100 + ("dirA", dummy_directory("Photos")), 101 + ]) 102 + .await; 103 + 104 + let mock = MockTransport::new(); 105 + // No remove_entry — doc has no parent. Just add_entry. 106 + mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 107 + mock.enqueue(put_record_response(DIR_A_URI)); 108 + 109 + let mut client = mock_client(mock.clone()); 110 + let source = source_doc(None); 111 + 112 + let result = move_entry(&mut client, &source, DIR_A_URI, "2026-03-01T12:00:00Z") 113 + .await 114 + .unwrap(); 115 + 116 + assert_eq!(result.uri, DOC_URI); 117 + assert_eq!(mock.requests().len(), 2); 118 + } 119 + 120 + #[tokio::test] 121 + async fn move_into_same_directory_rejected() { 122 + let _tree = load_tree_with(&[( 123 + "self", 124 + dummy_directory_with_entries("/", vec![DOC_URI.into()]), 125 + )]) 126 + .await; 127 + 128 + let mock = MockTransport::new(); 129 + let mut client = mock_client(mock); 130 + let source = source_doc(Some(ROOT_URI)); 131 + 132 + let err = move_entry(&mut client, &source, ROOT_URI, "2026-03-01T12:00:00Z") 133 + .await 134 + .unwrap_err(); 135 + 136 + assert!(err.to_string().contains("already in that directory")); 137 + } 138 + 139 + // -- cycle detection -- 140 + 141 + #[tokio::test] 142 + async fn cycle_self_reference() { 143 + let tree = load_tree_with(&[ 144 + ( 145 + "self", 146 + dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 147 + ), 148 + ("dirA", dummy_directory("Photos")), 149 + ]) 150 + .await; 151 + 152 + let err = check_cycle(&tree, DIR_A_URI, DIR_A_URI).unwrap_err(); 153 + assert!(err.to_string().contains("into itself")); 154 + } 155 + 156 + #[tokio::test] 157 + async fn cycle_into_descendant() { 158 + let tree = load_tree_with(&[ 159 + ( 160 + "self", 161 + dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 162 + ), 163 + ( 164 + "dirA", 165 + dummy_directory_with_entries("Photos", vec![DIR_B_URI.into()]), 166 + ), 167 + ("dirB", dummy_directory("Archive")), 168 + ]) 169 + .await; 170 + 171 + let err = check_cycle(&tree, DIR_A_URI, DIR_B_URI).unwrap_err(); 172 + assert!(err.to_string().contains("descendants")); 173 + } 174 + 175 + #[tokio::test] 176 + async fn no_cycle_for_sibling() { 177 + let tree = load_tree_with(&[ 178 + ( 179 + "self", 180 + dummy_directory_with_entries("/", vec![DIR_A_URI.into(), DIR_B_URI.into()]), 181 + ), 182 + ("dirA", dummy_directory("Photos")), 183 + ("dirB", dummy_directory("Archive")), 184 + ]) 185 + .await; 186 + 187 + check_cycle(&tree, DIR_A_URI, DIR_B_URI).unwrap(); 188 + }
-175
crates/opake-core/src/directories/mv.rs
··· 1 - // Move and rename operations for documents and directories. 2 - // 3 - // Move = re-parent (remove from old directory, add to new directory). 4 - // Rename = update the record's name field via putRecord. 5 - // Both can happen in a single `opake mv` invocation. 6 - 7 - use log::debug; 8 - 9 - use crate::atproto; 10 - use crate::client::{Transport, XrpcClient}; 11 - use crate::documents::DOCUMENT_COLLECTION; 12 - use crate::error::Error; 13 - use crate::records::{self, Directory, Document}; 14 - 15 - use super::entries::{add_entry, remove_entry}; 16 - use super::tree::{DirectoryTree, EntryKind, ResolvedPath}; 17 - use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY}; 18 - 19 - #[derive(Debug)] 20 - pub struct MoveResult { 21 - pub uri: String, 22 - pub new_name: Option<String>, 23 - } 24 - 25 - /// Move and/or rename a document or directory. 26 - /// 27 - /// `destination` is the resolved target — either a directory to move into, 28 - /// or a new name for the source. The caller (CLI) handles the ambiguity of 29 - /// destination interpretation before calling this. 30 - pub async fn move_entry( 31 - client: &mut XrpcClient<impl Transport>, 32 - _tree: &DirectoryTree, 33 - source: &ResolvedPath, 34 - destination: &MoveDestination, 35 - modified_at: &str, 36 - ) -> Result<MoveResult, Error> { 37 - match destination { 38 - MoveDestination::IntoDirectory { directory_uri } => { 39 - move_into_directory(client, source, directory_uri, modified_at).await 40 - } 41 - MoveDestination::Rename { new_name } => { 42 - rename_entry(client, source, new_name, modified_at).await 43 - } 44 - MoveDestination::MoveAndRename { 45 - directory_uri, 46 - new_name, 47 - } => { 48 - // Can't happen in current CLI UX, but the core supports it. 49 - move_into_directory(client, source, directory_uri, modified_at).await?; 50 - rename_entry(client, source, new_name, modified_at).await 51 - } 52 - } 53 - } 54 - 55 - /// What the destination resolves to. 56 - #[derive(Debug)] 57 - pub enum MoveDestination { 58 - /// Move into an existing directory, keeping the current name. 59 - IntoDirectory { directory_uri: String }, 60 - /// Rename in place (no directory change). 61 - Rename { new_name: String }, 62 - /// Move into a directory AND rename. 63 - MoveAndRename { 64 - directory_uri: String, 65 - new_name: String, 66 - }, 67 - } 68 - 69 - /// Check that moving a directory into a target doesn't create a cycle. 70 - /// 71 - /// A directory can't be moved into itself or any of its descendants. 72 - pub fn check_cycle( 73 - tree: &DirectoryTree, 74 - source_uri: &str, 75 - target_dir_uri: &str, 76 - ) -> Result<(), Error> { 77 - if source_uri == target_dir_uri { 78 - return Err(Error::InvalidRecord( 79 - "cannot move a directory into itself".into(), 80 - )); 81 - } 82 - 83 - let descendants = tree.collect_descendants(source_uri); 84 - for (uri, kind) in &descendants { 85 - if kind == &EntryKind::Directory && uri == target_dir_uri { 86 - return Err(Error::InvalidRecord( 87 - "cannot move a directory into one of its descendants".into(), 88 - )); 89 - } 90 - } 91 - 92 - Ok(()) 93 - } 94 - 95 - async fn move_into_directory( 96 - client: &mut XrpcClient<impl Transport>, 97 - source: &ResolvedPath, 98 - target_dir_uri: &str, 99 - modified_at: &str, 100 - ) -> Result<MoveResult, Error> { 101 - // Remove from old parent if tracked. 102 - if let Some(parent_uri) = &source.parent_uri { 103 - if parent_uri == target_dir_uri { 104 - return Err(Error::InvalidRecord(format!( 105 - "{:?} is already in that directory", 106 - source.name, 107 - ))); 108 - } 109 - debug!("removing {} from old parent {}", source.uri, parent_uri); 110 - remove_entry(client, parent_uri, &source.uri, modified_at).await?; 111 - } 112 - 113 - debug!("adding {} to new parent {}", source.uri, target_dir_uri); 114 - add_entry(client, target_dir_uri, &source.uri, modified_at).await?; 115 - 116 - Ok(MoveResult { 117 - uri: source.uri.clone(), 118 - new_name: None, 119 - }) 120 - } 121 - 122 - async fn rename_entry( 123 - client: &mut XrpcClient<impl Transport>, 124 - source: &ResolvedPath, 125 - new_name: &str, 126 - modified_at: &str, 127 - ) -> Result<MoveResult, Error> { 128 - let at_uri = atproto::parse_at_uri(&source.uri)?; 129 - 130 - match source.kind { 131 - EntryKind::Document => { 132 - let entry = client 133 - .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 134 - .await?; 135 - let mut doc: Document = serde_json::from_value(entry.value)?; 136 - records::check_version(doc.opake_version)?; 137 - 138 - doc.name = new_name.to_string(); 139 - doc.modified_at = Some(modified_at.to_string()); 140 - 141 - client 142 - .put_record(DOCUMENT_COLLECTION, &at_uri.rkey, &doc) 143 - .await?; 144 - } 145 - EntryKind::Directory => { 146 - if at_uri.rkey == ROOT_DIRECTORY_RKEY { 147 - return Err(Error::InvalidRecord( 148 - "cannot rename the root directory".into(), 149 - )); 150 - } 151 - 152 - let entry = client 153 - .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 154 - .await?; 155 - let mut dir: Directory = serde_json::from_value(entry.value)?; 156 - records::check_version(dir.opake_version)?; 157 - 158 - dir.name = new_name.to_string(); 159 - dir.modified_at = Some(modified_at.to_string()); 160 - 161 - client 162 - .put_record(DIRECTORY_COLLECTION, &at_uri.rkey, &dir) 163 - .await?; 164 - } 165 - } 166 - 167 - Ok(MoveResult { 168 - uri: source.uri.clone(), 169 - new_name: Some(new_name.to_string()), 170 - }) 171 - } 172 - 173 - #[cfg(test)] 174 - #[path = "mv_tests.rs"] 175 - mod tests;
-303
crates/opake-core/src/directories/mv_tests.rs
··· 1 - use super::*; 2 - use crate::client::{HttpResponse, RequestBody}; 3 - use crate::records::{Directory, Document}; 4 - use crate::test_utils::MockTransport; 5 - 6 - use super::super::tests::{ 7 - dummy_directory, dummy_directory_with_entries, get_record_response, list_records_response, 8 - mock_client, put_record_response, 9 - }; 10 - use crate::documents::tests::dummy_document; 11 - 12 - fn record_response<T: serde::Serialize>(uri: &str, record: &T) -> HttpResponse { 13 - HttpResponse { 14 - status: 200, 15 - headers: vec![], 16 - body: serde_json::to_vec(&serde_json::json!({ 17 - "uri": uri, 18 - "cid": "bafyrecord", 19 - "value": record, 20 - })) 21 - .unwrap(), 22 - } 23 - } 24 - 25 - const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 26 - const DIR_A_URI: &str = "at://did:plc:test/app.opake.directory/dirA"; 27 - const DIR_B_URI: &str = "at://did:plc:test/app.opake.directory/dirB"; 28 - const DOC_URI: &str = "at://did:plc:test/app.opake.document/doc1"; 29 - 30 - async fn load_tree_with(dirs: &[(&str, Directory)]) -> DirectoryTree { 31 - let mock = MockTransport::new(); 32 - mock.enqueue(list_records_response(dirs, None)); 33 - let mut client = mock_client(mock); 34 - DirectoryTree::load(&mut client).await.unwrap() 35 - } 36 - 37 - fn source_doc(parent_uri: Option<&str>) -> ResolvedPath { 38 - ResolvedPath { 39 - uri: DOC_URI.to_string(), 40 - kind: EntryKind::Document, 41 - name: "beach.jpg".to_string(), 42 - parent_uri: parent_uri.map(String::from), 43 - } 44 - } 45 - 46 - fn source_dir(uri: &str, name: &str, parent_uri: Option<&str>) -> ResolvedPath { 47 - ResolvedPath { 48 - uri: uri.to_string(), 49 - kind: EntryKind::Directory, 50 - name: name.to_string(), 51 - parent_uri: parent_uri.map(String::from), 52 - } 53 - } 54 - 55 - // -- move into directory -- 56 - 57 - #[tokio::test] 58 - async fn move_doc_into_directory() { 59 - let tree = load_tree_with(&[ 60 - ( 61 - "self", 62 - dummy_directory_with_entries("/", vec![DOC_URI.into()]), 63 - ), 64 - ("dirA", dummy_directory("Photos")), 65 - ]) 66 - .await; 67 - 68 - let mock = MockTransport::new(); 69 - // remove_entry: get old parent, put old parent 70 - mock.enqueue(get_record_response( 71 - ROOT_URI, 72 - &dummy_directory_with_entries("/", vec![DOC_URI.into()]), 73 - )); 74 - mock.enqueue(put_record_response(ROOT_URI)); 75 - // add_entry: get new parent, put new parent 76 - mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 77 - mock.enqueue(put_record_response(DIR_A_URI)); 78 - 79 - let mut client = mock_client(mock.clone()); 80 - let source = source_doc(Some(ROOT_URI)); 81 - let dest = MoveDestination::IntoDirectory { 82 - directory_uri: DIR_A_URI.to_string(), 83 - }; 84 - 85 - let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 86 - .await 87 - .unwrap(); 88 - 89 - assert_eq!(result.uri, DOC_URI); 90 - assert!(result.new_name.is_none()); 91 - 92 - let reqs = mock.requests(); 93 - assert_eq!(reqs.len(), 4); 94 - 95 - // Old parent should have doc removed 96 - match &reqs[1].body { 97 - Some(RequestBody::Json(v)) => { 98 - let dir: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 99 - assert!(dir.entries.is_empty()); 100 - } 101 - _ => panic!("expected JSON body"), 102 - } 103 - 104 - // New parent should have doc added 105 - match &reqs[3].body { 106 - Some(RequestBody::Json(v)) => { 107 - let dir: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 108 - assert_eq!(dir.entries, vec![DOC_URI]); 109 - } 110 - _ => panic!("expected JSON body"), 111 - } 112 - } 113 - 114 - #[tokio::test] 115 - async fn move_untracked_doc_into_directory() { 116 - let tree = load_tree_with(&[ 117 - ("self", dummy_directory("/")), 118 - ("dirA", dummy_directory("Photos")), 119 - ]) 120 - .await; 121 - 122 - let mock = MockTransport::new(); 123 - // No remove_entry — doc has no parent. Just add_entry. 124 - mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 125 - mock.enqueue(put_record_response(DIR_A_URI)); 126 - 127 - let mut client = mock_client(mock.clone()); 128 - let source = source_doc(None); 129 - let dest = MoveDestination::IntoDirectory { 130 - directory_uri: DIR_A_URI.to_string(), 131 - }; 132 - 133 - let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 134 - .await 135 - .unwrap(); 136 - 137 - assert_eq!(result.uri, DOC_URI); 138 - assert_eq!(mock.requests().len(), 2); 139 - } 140 - 141 - #[tokio::test] 142 - async fn move_into_same_directory_rejected() { 143 - let tree = load_tree_with(&[( 144 - "self", 145 - dummy_directory_with_entries("/", vec![DOC_URI.into()]), 146 - )]) 147 - .await; 148 - 149 - let mock = MockTransport::new(); 150 - let mut client = mock_client(mock); 151 - let source = source_doc(Some(ROOT_URI)); 152 - let dest = MoveDestination::IntoDirectory { 153 - directory_uri: ROOT_URI.to_string(), 154 - }; 155 - 156 - let err = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 157 - .await 158 - .unwrap_err(); 159 - 160 - assert!(err.to_string().contains("already in that directory")); 161 - } 162 - 163 - // -- rename -- 164 - 165 - #[tokio::test] 166 - async fn rename_document() { 167 - let tree = load_tree_with(&[("self", dummy_directory("/"))]).await; 168 - 169 - let doc = dummy_document("beach.jpg", 1000, vec![]); 170 - let mock = MockTransport::new(); 171 - mock.enqueue(record_response(DOC_URI, &doc)); 172 - mock.enqueue(put_record_response(DOC_URI)); 173 - 174 - let mut client = mock_client(mock.clone()); 175 - let source = source_doc(Some(ROOT_URI)); 176 - let dest = MoveDestination::Rename { 177 - new_name: "sunset.jpg".to_string(), 178 - }; 179 - 180 - let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 181 - .await 182 - .unwrap(); 183 - 184 - assert_eq!(result.uri, DOC_URI); 185 - assert_eq!(result.new_name.as_deref(), Some("sunset.jpg")); 186 - 187 - let reqs = mock.requests(); 188 - match &reqs[1].body { 189 - Some(RequestBody::Json(v)) => { 190 - let updated: Document = serde_json::from_value(v["record"].clone()).unwrap(); 191 - assert_eq!(updated.name, "sunset.jpg"); 192 - assert_eq!(updated.modified_at.unwrap(), "2026-03-01T12:00:00Z"); 193 - } 194 - _ => panic!("expected JSON body"), 195 - } 196 - } 197 - 198 - #[tokio::test] 199 - async fn rename_directory() { 200 - let tree = load_tree_with(&[ 201 - ( 202 - "self", 203 - dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 204 - ), 205 - ("dirA", dummy_directory("Photos")), 206 - ]) 207 - .await; 208 - 209 - let mock = MockTransport::new(); 210 - mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 211 - mock.enqueue(put_record_response(DIR_A_URI)); 212 - 213 - let mut client = mock_client(mock.clone()); 214 - let source = source_dir(DIR_A_URI, "Photos", Some(ROOT_URI)); 215 - let dest = MoveDestination::Rename { 216 - new_name: "Memories".to_string(), 217 - }; 218 - 219 - let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 220 - .await 221 - .unwrap(); 222 - 223 - assert_eq!(result.uri, DIR_A_URI); 224 - assert_eq!(result.new_name.as_deref(), Some("Memories")); 225 - 226 - let reqs = mock.requests(); 227 - match &reqs[1].body { 228 - Some(RequestBody::Json(v)) => { 229 - let updated: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 230 - assert_eq!(updated.name, "Memories"); 231 - } 232 - _ => panic!("expected JSON body"), 233 - } 234 - } 235 - 236 - #[tokio::test] 237 - async fn rename_root_directory_rejected() { 238 - let tree = load_tree_with(&[("self", dummy_directory("/"))]).await; 239 - 240 - let mock = MockTransport::new(); 241 - let mut client = mock_client(mock); 242 - let source = source_dir(ROOT_URI, "/", None); 243 - let dest = MoveDestination::Rename { 244 - new_name: "newroot".to_string(), 245 - }; 246 - 247 - let err = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 248 - .await 249 - .unwrap_err(); 250 - 251 - assert!(err.to_string().contains("cannot rename the root directory")); 252 - } 253 - 254 - // -- cycle detection -- 255 - 256 - #[tokio::test] 257 - async fn cycle_self_reference() { 258 - let tree = load_tree_with(&[ 259 - ( 260 - "self", 261 - dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 262 - ), 263 - ("dirA", dummy_directory("Photos")), 264 - ]) 265 - .await; 266 - 267 - let err = check_cycle(&tree, DIR_A_URI, DIR_A_URI).unwrap_err(); 268 - assert!(err.to_string().contains("into itself")); 269 - } 270 - 271 - #[tokio::test] 272 - async fn cycle_into_descendant() { 273 - let tree = load_tree_with(&[ 274 - ( 275 - "self", 276 - dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 277 - ), 278 - ( 279 - "dirA", 280 - dummy_directory_with_entries("Photos", vec![DIR_B_URI.into()]), 281 - ), 282 - ("dirB", dummy_directory("Archive")), 283 - ]) 284 - .await; 285 - 286 - let err = check_cycle(&tree, DIR_A_URI, DIR_B_URI).unwrap_err(); 287 - assert!(err.to_string().contains("descendants")); 288 - } 289 - 290 - #[tokio::test] 291 - async fn no_cycle_for_sibling() { 292 - let tree = load_tree_with(&[ 293 - ( 294 - "self", 295 - dummy_directory_with_entries("/", vec![DIR_A_URI.into(), DIR_B_URI.into()]), 296 - ), 297 - ("dirA", dummy_directory("Photos")), 298 - ("dirB", dummy_directory("Archive")), 299 - ]) 300 - .await; 301 - 302 - check_cycle(&tree, DIR_A_URI, DIR_B_URI).unwrap(); 303 - }
+31 -3
crates/opake-core/src/documents/download.rs
··· 25 25 crypto::decrypt_blob(content_key, &crypto::EncryptedPayload { ciphertext, nonce }) 26 26 } 27 27 28 + /// Resolve a document's name from encrypted metadata if present, falling 29 + /// back to the plaintext `name` field for pre-encryption records. 30 + pub(super) fn resolve_document_name( 31 + doc: &Document, 32 + content_key: &ContentKey, 33 + ) -> Result<String, Error> { 34 + let metadata = crypto::decrypt_metadata(content_key, &doc.encrypted_metadata)?; 35 + Ok(metadata.name) 36 + } 37 + 28 38 /// Backwards-compat wrapper used by download_grant. 29 39 pub(super) fn decrypt_with_envelope( 30 40 content_key: &ContentKey, ··· 157 167 .await?; 158 168 159 169 let plaintext = decrypt_with_nonce(&content_key, nonce, ciphertext)?; 160 - Ok((doc.name, plaintext)) 170 + let name = resolve_document_name(&doc, &content_key)?; 171 + Ok((name, plaintext)) 161 172 } 162 173 163 174 #[cfg(test)] ··· 169 180 use crate::test_utils::MockTransport; 170 181 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 171 182 172 - use super::super::tests::{mock_client, TEST_DID, TEST_URI}; 183 + use super::super::tests::{dummy_encrypted_metadata, mock_client, TEST_DID, TEST_URI}; 173 184 174 185 fn test_keypair() -> (X25519PublicKey, X25519PrivateKey) { 175 186 let secret = crypto::X25519DalekStaticSecret::random_from_rng(OsRng); ··· 181 192 ciphertext: Vec<u8>, 182 193 nonce: [u8; 12], 183 194 wrapped_key: records::WrappedKey, 195 + content_key: crypto::ContentKey, 184 196 } 185 197 186 198 fn encrypt_for_download(plaintext: &[u8], public_key: &X25519PublicKey) -> EncryptedFixture { ··· 192 204 ciphertext: payload.ciphertext, 193 205 nonce: payload.nonce, 194 206 wrapped_key, 207 + content_key, 195 208 } 196 209 } 197 210 198 211 fn document_from_fixture(fixture: &EncryptedFixture) -> Document { 212 + let metadata = crypto::DocumentMetadata { 213 + name: "test-file.txt".into(), 214 + mime_type: Some("text/plain".into()), 215 + size: Some(42), 216 + tags: vec![], 217 + description: None, 218 + }; 219 + let encrypted_metadata = 220 + crypto::encrypt_metadata(&fixture.content_key, &metadata, &mut OsRng).unwrap(); 221 + 199 222 Document { 200 223 mime_type: Some("text/plain".into()), 201 224 size: Some(42), 202 225 visibility: Some("private".into()), 203 226 ..Document::new( 204 - "test-file.txt".into(), 227 + "encrypted".into(), 205 228 BlobRef { 206 229 blob_type: "blob".into(), 207 230 reference: CidLink { ··· 219 242 keys: vec![fixture.wrapped_key.clone()], 220 243 }, 221 244 }), 245 + encrypted_metadata, 222 246 "2026-03-01T00:00:00Z".into(), 223 247 ) 224 248 } ··· 329 353 "rotation": 1, 330 354 }, 331 355 "algo": "aes-256-gcm", 356 + "nonce": { "$bytes": "AAAAAAAAAAAAAAAA" }, 357 + }, 358 + "encryptedMetadata": { 359 + "ciphertext": { "$bytes": "AAAA" }, 332 360 "nonce": { "$bytes": "AAAAAAAAAAAAAAAA" }, 333 361 }, 334 362 "createdAt": "2026-03-01T00:00:00Z",
+52 -33
crates/opake-core/src/documents/download_grant.rs
··· 9 9 use crate::records::{self, Document, Grant}; 10 10 use crate::sharing::GRANT_COLLECTION; 11 11 12 - use super::download::decrypt_with_envelope; 12 + use super::download::{decrypt_with_envelope, resolve_document_name}; 13 13 14 14 /// Download and decrypt a file using a grant URI. 15 15 /// ··· 97 97 .await?; 98 98 99 99 let plaintext = decrypt_with_envelope(&content_key, envelope, ciphertext)?; 100 - Ok((doc.name, plaintext)) 100 + let name = resolve_document_name(&doc, &content_key)?; 101 + Ok((name, plaintext)) 101 102 } 102 103 103 104 #[cfg(test)] ··· 106 107 use crate::client::HttpResponse; 107 108 use crate::crypto::{OsRng, X25519PublicKey}; 108 109 use crate::records::{ 109 - AtBytes, BlobRef, CidLink, DirectEncryption, Encryption, EncryptionEnvelope, 110 + AtBytes, BlobRef, CidLink, DirectEncryption, EncryptedMetadata, Encryption, 111 + EncryptionEnvelope, 110 112 }; 111 113 use crate::test_utils::MockTransport; 112 114 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; ··· 168 170 } 169 171 } 170 172 173 + struct GrantFixture { 174 + payload: crypto::EncryptedPayload, 175 + owner_wrapped: records::WrappedKey, 176 + recipient_wrapped: records::WrappedKey, 177 + content_key: crypto::ContentKey, 178 + } 179 + 180 + fn encrypt_and_wrap(plaintext: &[u8], recipient_public: &X25519PublicKey) -> GrantFixture { 181 + let content_key = crypto::generate_content_key(&mut OsRng); 182 + let payload = crypto::encrypt_blob(&content_key, plaintext, &mut OsRng).unwrap(); 183 + let owner_wrapped = 184 + crypto::wrap_key(&content_key, &[99u8; 32], OWNER_DID, &mut OsRng).unwrap(); 185 + let recipient_wrapped = crypto::wrap_key( 186 + &content_key, 187 + recipient_public, 188 + "did:plc:recipient", 189 + &mut OsRng, 190 + ) 191 + .unwrap(); 192 + GrantFixture { 193 + payload, 194 + owner_wrapped, 195 + recipient_wrapped, 196 + content_key, 197 + } 198 + } 199 + 171 200 fn make_document( 172 201 name: &str, 173 202 ciphertext_len: usize, 174 203 nonce: &[u8; 12], 175 204 owner_wrapped: records::WrappedKey, 205 + content_key: &crypto::ContentKey, 176 206 ) -> Document { 207 + let metadata = crypto::DocumentMetadata { 208 + name: name.into(), 209 + mime_type: Some("text/plain".into()), 210 + size: Some(42), 211 + tags: vec![], 212 + description: None, 213 + }; 214 + let encrypted_metadata = 215 + crypto::encrypt_metadata(content_key, &metadata, &mut OsRng).unwrap(); 216 + 177 217 Document { 178 218 mime_type: Some("text/plain".into()), 179 219 size: Some(42), 180 220 ..Document::new( 181 - name.into(), 221 + "encrypted".into(), 182 222 BlobRef { 183 223 blob_type: "blob".into(), 184 224 reference: CidLink { ··· 196 236 keys: vec![owner_wrapped], 197 237 }, 198 238 }), 239 + encrypted_metadata, 199 240 "2026-03-01T00:00:00Z".into(), 200 241 ) 201 242 } 202 243 } 203 244 204 - fn encrypt_and_wrap( 205 - plaintext: &[u8], 206 - recipient_public: &X25519PublicKey, 207 - ) -> ( 208 - crypto::EncryptedPayload, 209 - records::WrappedKey, 210 - records::WrappedKey, 211 - ) { 212 - let content_key = crypto::generate_content_key(&mut OsRng); 213 - let payload = crypto::encrypt_blob(&content_key, plaintext, &mut OsRng).unwrap(); 214 - let owner_wrapped = 215 - crypto::wrap_key(&content_key, &[99u8; 32], OWNER_DID, &mut OsRng).unwrap(); 216 - let recipient_wrapped = crypto::wrap_key( 217 - &content_key, 218 - recipient_public, 219 - "did:plc:recipient", 220 - &mut OsRng, 221 - ) 222 - .unwrap(); 223 - (payload, owner_wrapped, recipient_wrapped) 224 - } 225 - 226 245 #[tokio::test] 227 246 async fn roundtrip() { 228 247 let recipient_secret = crypto::X25519DalekStaticSecret::random_from_rng(OsRng); ··· 230 249 let recipient_private = recipient_secret.to_bytes(); 231 250 232 251 let plaintext = b"shared secret content"; 233 - let (payload, owner_wrapped, recipient_wrapped) = 234 - encrypt_and_wrap(plaintext, recipient_public.as_bytes()); 252 + let fixture = encrypt_and_wrap(plaintext, recipient_public.as_bytes()); 235 253 236 254 let doc = make_document( 237 255 "shared-file.txt", 238 - payload.ciphertext.len(), 239 - &payload.nonce, 240 - owner_wrapped, 256 + fixture.payload.ciphertext.len(), 257 + &fixture.payload.nonce, 258 + fixture.owner_wrapped, 259 + &fixture.content_key, 241 260 ); 242 261 243 262 let grant = Grant::new( 244 263 DOC_URI.to_string(), 245 264 "did:plc:recipient".to_string(), 246 - recipient_wrapped, 265 + fixture.recipient_wrapped, 247 266 "2026-03-01T12:00:00Z".to_string(), 248 267 ); 249 268 ··· 251 270 mock.enqueue(did_document_response()); 252 271 mock.enqueue(grant_record_response(&grant)); 253 272 mock.enqueue(record_response(&doc)); 254 - mock.enqueue(blob_response(&payload.ciphertext)); 273 + mock.enqueue(blob_response(&fixture.payload.ciphertext)); 255 274 256 275 let (name, decrypted) = download_from_grant(&mock, &recipient_private, GRANT_URI) 257 276 .await
+3 -2
crates/opake-core/src/documents/download_keyring.rs
··· 9 9 use crate::keyrings::KEYRING_COLLECTION; 10 10 use crate::records::{self, Document, Encryption, Keyring}; 11 11 12 - use super::download::decrypt_with_nonce; 12 + use super::download::{decrypt_with_nonce, resolve_document_name}; 13 13 use super::DOCUMENT_COLLECTION; 14 14 15 15 /// Result of downloading a keyring-encrypted document as a member. ··· 138 138 get_blob_public(transport, &owner_pds, owner_did, &doc.blob.reference.cid).await?; 139 139 140 140 let plaintext = decrypt_with_nonce(&content_key, &kr_enc.nonce, ciphertext)?; 141 + let filename = resolve_document_name(&doc, &content_key)?; 141 142 142 143 Ok(KeyringDownloadResult { 143 - filename: doc.name, 144 + filename, 144 145 plaintext, 145 146 group_key, 146 147 keyring_rkey: kr_at.rkey,
+28 -3
crates/opake-core/src/documents/download_keyring_tests.rs
··· 1 1 use super::*; 2 2 use crate::client::HttpResponse; 3 3 use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 4 - use crate::records::{AtBytes, BlobRef, CidLink, KeyringEncryption, KeyringRef, WrappedKey}; 4 + use crate::records::{ 5 + AtBytes, BlobRef, CidLink, EncryptedMetadata, KeyringEncryption, KeyringRef, WrappedKey, 6 + }; 5 7 use crate::test_utils::MockTransport; 6 8 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 7 9 ··· 55 57 struct KeyringFixture { 56 58 ciphertext: Vec<u8>, 57 59 nonce: [u8; 12], 60 + content_key: ContentKey, 58 61 group_key: ContentKey, 59 62 owner_wrapped_gk: WrappedKey, 60 63 member_wrapped_gk: WrappedKey, ··· 82 85 KeyringFixture { 83 86 ciphertext: payload.ciphertext, 84 87 nonce: payload.nonce, 88 + content_key, 85 89 group_key, 86 90 owner_wrapped_gk, 87 91 member_wrapped_gk, ··· 90 94 } 91 95 92 96 fn keyring_document_at_rotation(fixture: &KeyringFixture, rotation: u64) -> Document { 97 + let metadata = crypto::DocumentMetadata { 98 + name: "keyring-file.txt".into(), 99 + mime_type: Some("text/plain".into()), 100 + size: Some(42), 101 + tags: vec![], 102 + description: None, 103 + }; 104 + let encrypted_metadata = 105 + crypto::encrypt_metadata(&fixture.content_key, &metadata, &mut OsRng).unwrap(); 106 + 93 107 Document { 94 108 mime_type: Some("text/plain".into()), 95 109 size: Some(42), 96 110 ..Document::new( 97 - "keyring-file.txt".into(), 111 + "encrypted".into(), 98 112 BlobRef { 99 113 blob_type: "blob".into(), 100 114 reference: CidLink { ··· 116 130 encoded: BASE64.encode(fixture.nonce), 117 131 }, 118 132 }), 133 + encrypted_metadata, 119 134 "2026-03-01T00:00:00Z".into(), 120 135 ) 121 136 } ··· 207 222 let payload = crypto::encrypt_blob(&content_key, b"data", &mut OsRng).unwrap(); 208 223 let wrapped = crypto::wrap_key(&content_key, &member_pub, MEMBER_DID, &mut OsRng).unwrap(); 209 224 225 + let metadata = crypto::DocumentMetadata { 226 + name: "direct-file.txt".into(), 227 + mime_type: Some("text/plain".into()), 228 + size: Some(4), 229 + tags: vec![], 230 + description: None, 231 + }; 232 + let encrypted_metadata = crypto::encrypt_metadata(&content_key, &metadata, &mut OsRng).unwrap(); 233 + 210 234 let doc = Document { 211 235 mime_type: Some("text/plain".into()), 212 236 size: Some(4), 213 237 ..Document::new( 214 - "direct-file.txt".into(), 238 + "encrypted".into(), 215 239 BlobRef { 216 240 blob_type: "blob".into(), 217 241 reference: CidLink { ··· 229 253 keys: vec![wrapped], 230 254 }, 231 255 }), 256 + encrypted_metadata, 232 257 "2026-03-01T00:00:00Z".into(), 233 258 ) 234 259 };
+11 -1
crates/opake-core/src/documents/list.rs
··· 1 1 use crate::client::{list_collection, Transport, XrpcClient}; 2 2 use crate::error::Error; 3 - use crate::records::Document; 3 + use crate::records::{Document, EncryptedMetadata, Encryption}; 4 4 5 5 use super::DOCUMENT_COLLECTION; 6 6 7 7 /// A document listing entry with its AT-URI and parsed metadata. 8 + /// 9 + /// A document listing entry with its AT-URI and parsed metadata. 10 + /// 11 + /// The `name`/`size`/`mime_type`/`tags` fields contain dummy placeholder 12 + /// values. Callers must decrypt `encrypted_metadata` using the content key 13 + /// (unwrapped from `encryption`) to get real values. 8 14 #[derive(Debug)] 9 15 pub struct DocumentEntry { 10 16 pub uri: String, ··· 13 19 pub mime_type: Option<String>, 14 20 pub tags: Vec<String>, 15 21 pub created_at: String, 22 + pub encrypted_metadata: EncryptedMetadata, 23 + pub encryption: Encryption, 16 24 } 17 25 18 26 /// Fetch all document records, paginating through the full collection. ··· 29 37 mime_type: doc.mime_type, 30 38 tags: doc.tags, 31 39 created_at: doc.created_at, 40 + encrypted_metadata: doc.encrypted_metadata, 41 + encryption: doc.encryption, 32 42 } 33 43 }) 34 44 .await
+15 -2
crates/opake-core/src/documents/mod.rs
··· 21 21 pub use resolve::resolve_uri; 22 22 pub use upload::{ 23 23 encrypt_and_upload, encrypt_and_upload_keyring, KeyringUploadParams, UploadParams, 24 + OPAQUE_PLACEHOLDER_NAME, 24 25 }; 25 26 26 27 pub const DOCUMENT_COLLECTION: &str = "app.opake.document"; ··· 31 32 32 33 use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 33 34 use crate::records::{ 34 - AtBytes, BlobRef, CidLink, DirectEncryption, Document, Encryption, EncryptionEnvelope, 35 - WrappedKey, 35 + AtBytes, BlobRef, CidLink, DirectEncryption, Document, EncryptedMetadata, Encryption, 36 + EncryptionEnvelope, WrappedKey, 36 37 }; 37 38 use crate::test_utils::MockTransport; 38 39 ··· 49 50 XrpcClient::with_session(mock, "https://pds.test".into(), session) 50 51 } 51 52 53 + pub fn dummy_encrypted_metadata() -> EncryptedMetadata { 54 + EncryptedMetadata { 55 + ciphertext: AtBytes { 56 + encoded: BASE64.encode([0u8; 32]), 57 + }, 58 + nonce: AtBytes { 59 + encoded: BASE64.encode([0u8; 12]), 60 + }, 61 + } 62 + } 63 + 52 64 pub fn dummy_document(name: &str, size: u64, tags: Vec<String>) -> Document { 53 65 Document { 54 66 mime_type: Some("text/plain".into()), ··· 80 92 }], 81 93 }, 82 94 }), 95 + dummy_encrypted_metadata(), 83 96 "2026-03-01T00:00:00Z".into(), 84 97 ) 85 98 }
+122 -30
crates/opake-core/src/documents/upload.rs
··· 2 2 use log::debug; 3 3 4 4 use crate::client::{Transport, XrpcClient}; 5 - use crate::crypto::{self, ContentKey, CryptoRng, RngCore, X25519PublicKey}; 5 + use crate::crypto::{self, ContentKey, CryptoRng, DocumentMetadata, RngCore, X25519PublicKey}; 6 6 use crate::error::Error; 7 7 use crate::records::{ 8 8 AtBytes, DirectEncryption, Document, Encryption, EncryptionEnvelope, KeyringEncryption, ··· 14 14 /// Maximum blob size accepted by a standard PDS (50 MB). 15 15 const MAX_BLOB_SIZE: usize = 50 * 1024 * 1024; 16 16 17 + /// Plaintext name written to the record when metadata is encrypted. 18 + pub const OPAQUE_PLACEHOLDER_NAME: &str = "encrypted"; 19 + 20 + /// Plaintext MIME type written to the record when metadata is encrypted. 21 + pub const OPAQUE_PLACEHOLDER_MIME: &str = "application/octet-stream"; 22 + 23 + /// Build a `DocumentMetadata` from upload parameters and encrypt it. 24 + fn build_encrypted_metadata( 25 + content_key: &crypto::ContentKey, 26 + filename: &str, 27 + mime_type: &str, 28 + size: u64, 29 + description: Option<&str>, 30 + rng: &mut (impl CryptoRng + RngCore), 31 + ) -> Result<crate::records::EncryptedMetadata, Error> { 32 + let metadata = DocumentMetadata { 33 + name: filename.into(), 34 + mime_type: Some(mime_type.into()), 35 + size: Some(size), 36 + tags: vec![], 37 + description: description.map(Into::into), 38 + }; 39 + crypto::encrypt_metadata(content_key, &metadata, rng) 40 + } 41 + 17 42 /// Everything needed to encrypt and upload a document, minus the transport 18 43 /// and RNG (which are passed separately). 19 44 pub struct UploadParams<'a> { ··· 22 47 pub mime_type: &'a str, 23 48 pub owner_did: &'a str, 24 49 pub owner_pubkey: &'a X25519PublicKey, 25 - pub tags: Vec<String>, 50 + pub description: Option<&'a str>, 26 51 pub created_at: &'a str, 27 52 } 28 53 ··· 30 55 /// owner's public key, and create the document record. Returns the AT-URI of 31 56 /// the created record. 32 57 /// 33 - /// The caller is responsible for reading the file from disk, detecting the MIME 34 - /// type, and extracting the filename — this function is platform-agnostic. 58 + /// Metadata (name, mimeType, size, tags, description) is always encrypted 59 + /// alongside the blob using the same content key. The plaintext record fields 60 + /// are set to dummy values. 35 61 pub async fn encrypt_and_upload( 36 62 client: &mut XrpcClient<impl Transport>, 37 63 params: &UploadParams<'_>, ··· 66 92 67 93 let wrapped_key = crypto::wrap_key(&content_key, params.owner_pubkey, params.owner_did, rng)?; 68 94 95 + let encrypted_metadata = build_encrypted_metadata( 96 + &content_key, 97 + params.filename, 98 + params.mime_type, 99 + params.plaintext.len() as u64, 100 + params.description, 101 + rng, 102 + )?; 103 + 69 104 let document = Document { 70 - mime_type: Some(params.mime_type.into()), 71 - size: Some(params.plaintext.len() as u64), 72 - tags: params.tags.clone(), 105 + mime_type: Some(OPAQUE_PLACEHOLDER_MIME.into()), 73 106 visibility: Some("private".into()), 74 107 ..Document::new( 75 - params.filename.into(), 108 + OPAQUE_PLACEHOLDER_NAME.into(), 76 109 blob_ref, 77 110 Encryption::Direct(DirectEncryption { 78 111 envelope: EncryptionEnvelope { ··· 83 116 keys: vec![wrapped_key], 84 117 }, 85 118 }), 119 + encrypted_metadata, 86 120 params.created_at.into(), 87 121 ) 88 122 }; ··· 100 134 pub keyring_uri: &'a str, 101 135 pub group_key: &'a ContentKey, 102 136 pub rotation: u64, 103 - pub tags: Vec<String>, 137 + pub description: Option<&'a str>, 104 138 pub created_at: &'a str, 105 139 } 106 140 ··· 140 174 141 175 let wrapped_content_key = crypto::wrap_content_key_for_keyring(&content_key, params.group_key)?; 142 176 177 + let encrypted_metadata = build_encrypted_metadata( 178 + &content_key, 179 + params.filename, 180 + params.mime_type, 181 + params.plaintext.len() as u64, 182 + params.description, 183 + rng, 184 + )?; 185 + 143 186 let document = Document { 144 - mime_type: Some(params.mime_type.into()), 145 - size: Some(params.plaintext.len() as u64), 146 - tags: params.tags.clone(), 187 + mime_type: Some(OPAQUE_PLACEHOLDER_MIME.into()), 147 188 visibility: Some("private".into()), 148 189 ..Document::new( 149 - params.filename.into(), 190 + OPAQUE_PLACEHOLDER_NAME.into(), 150 191 blob_ref, 151 192 Encryption::Keyring(KeyringEncryption { 152 193 keyring_ref: KeyringRef { ··· 161 202 encoded: BASE64.encode(payload.nonce), 162 203 }, 163 204 }), 205 + encrypted_metadata, 164 206 params.created_at.into(), 165 207 ) 166 208 }; ··· 220 262 plaintext: &'a [u8], 221 263 filename: &'a str, 222 264 public_key: &'a X25519PublicKey, 223 - tags: Vec<String>, 224 265 ) -> UploadParams<'a> { 225 266 UploadParams { 226 267 plaintext, ··· 228 269 mime_type: "text/plain", 229 270 owner_did: TEST_DID, 230 271 owner_pubkey: public_key, 231 - tags, 272 + description: None, 232 273 created_at: "2026-03-01T00:00:00Z", 233 274 } 234 275 } ··· 241 282 mock.enqueue(create_record_response()); 242 283 243 284 let mut client = mock_client(mock.clone()); 244 - let params = test_params( 245 - b"hello world", 246 - "hello.txt", 247 - &public_key, 248 - vec!["test".into()], 249 - ); 285 + let params = test_params(b"hello world", "hello.txt", &public_key); 250 286 let uri = encrypt_and_upload(&mut client, &params, &mut OsRng) 251 287 .await 252 288 .unwrap(); ··· 264 300 Some(RequestBody::Json(v)) => { 265 301 assert_eq!(v["collection"], "app.opake.document"); 266 302 let record = &v["record"]; 267 - assert_eq!(record["name"], "hello.txt"); 268 - assert_eq!(record["mimeType"], "text/plain"); 269 - assert_eq!(record["size"], 11); 270 - assert_eq!(record["tags"], serde_json::json!(["test"])); 303 + 304 + // Plaintext fields are dummies 305 + assert_eq!(record["name"], OPAQUE_PLACEHOLDER_NAME); 306 + assert_eq!(record["mimeType"], OPAQUE_PLACEHOLDER_MIME); 307 + assert!(record.get("size").is_none() || record["size"].is_null()); 308 + assert!(record.get("tags").is_none()); 271 309 assert_eq!(record["visibility"], "private"); 272 310 311 + // Encrypted metadata is present 312 + assert!( 313 + record.get("encryptedMetadata").is_some(), 314 + "should have encryptedMetadata" 315 + ); 316 + let em = &record["encryptedMetadata"]; 317 + assert!(em["ciphertext"]["$bytes"].is_string()); 318 + assert!(em["nonce"]["$bytes"].is_string()); 319 + 273 320 // Verify encryption envelope structure 274 321 let enc = &record["encryption"]; 275 322 assert_eq!(enc["envelope"]["algo"], "aes-256-gcm"); ··· 282 329 } 283 330 284 331 #[tokio::test] 332 + async fn encrypted_metadata_decrypts_to_original() { 333 + let (public_key, private_key) = test_keypair(); 334 + let mock = MockTransport::new(); 335 + mock.enqueue(upload_blob_response()); 336 + mock.enqueue(create_record_response()); 337 + 338 + let mut client = mock_client(mock.clone()); 339 + let params = UploadParams { 340 + plaintext: b"test data", 341 + filename: "report.pdf", 342 + mime_type: "application/pdf", 343 + owner_did: TEST_DID, 344 + owner_pubkey: &public_key, 345 + description: Some("Quarterly report"), 346 + created_at: "2026-03-01T00:00:00Z", 347 + }; 348 + encrypt_and_upload(&mut client, &params, &mut OsRng) 349 + .await 350 + .unwrap(); 351 + 352 + let requests = mock.requests(); 353 + let create_body = match &requests[1].body { 354 + Some(RequestBody::Json(v)) => v.clone(), 355 + _ => panic!("expected JSON body"), 356 + }; 357 + let doc: Document = serde_json::from_value(create_body["record"].clone()).unwrap(); 358 + 359 + // Unwrap content key 360 + let envelope = match &doc.encryption { 361 + Encryption::Direct(d) => &d.envelope, 362 + _ => panic!("expected direct encryption"), 363 + }; 364 + let content_key = crypto::unwrap_key(&envelope.keys[0], &private_key).unwrap(); 365 + 366 + // Decrypt metadata 367 + let metadata = crypto::decrypt_metadata(&content_key, &doc.encrypted_metadata).unwrap(); 368 + 369 + assert_eq!(metadata.name, "report.pdf"); 370 + assert_eq!(metadata.mime_type.as_deref(), Some("application/pdf")); 371 + assert_eq!(metadata.size, Some(9)); 372 + assert!(metadata.tags.is_empty()); 373 + assert_eq!(metadata.description.as_deref(), Some("Quarterly report")); 374 + } 375 + 376 + #[tokio::test] 285 377 async fn rejects_oversized_blob() { 286 378 let (public_key, _) = test_keypair(); 287 379 let mock = MockTransport::new(); 288 380 let mut client = mock_client(mock); 289 381 290 382 let oversized = vec![0u8; MAX_BLOB_SIZE + 1]; 291 - let params = test_params(&oversized, "big.bin", &public_key, vec![]); 383 + let params = test_params(&oversized, "big.bin", &public_key); 292 384 let err = encrypt_and_upload(&mut client, &params, &mut OsRng) 293 385 .await 294 386 .unwrap_err(); ··· 304 396 mock.enqueue(create_record_response()); 305 397 306 398 let mut client = mock_client(mock); 307 - let params = test_params(b"", "empty.txt", &public_key, vec![]); 399 + let params = test_params(b"", "empty.txt", &public_key); 308 400 let uri = encrypt_and_upload(&mut client, &params, &mut OsRng) 309 401 .await 310 402 .unwrap(); ··· 323 415 }); 324 416 325 417 let mut client = mock_client(mock); 326 - let params = test_params(b"data", "file.bin", &public_key, vec![]); 418 + let params = test_params(b"data", "file.bin", &public_key); 327 419 let err = encrypt_and_upload(&mut client, &params, &mut OsRng) 328 420 .await 329 421 .unwrap_err(); ··· 343 435 }); 344 436 345 437 let mut client = mock_client(mock); 346 - let params = test_params(b"data", "file.bin", &public_key, vec![]); 438 + let params = test_params(b"data", "file.bin", &public_key); 347 439 let err = encrypt_and_upload(&mut client, &params, &mut OsRng) 348 440 .await 349 441 .unwrap_err(); ··· 361 453 mock.enqueue(create_record_response()); 362 454 363 455 let mut client = mock_client(mock.clone()); 364 - let params = test_params(plaintext, "roundtrip.txt", &public_key, vec![]); 456 + let params = test_params(plaintext, "roundtrip.txt", &public_key); 365 457 encrypt_and_upload(&mut client, &params, &mut OsRng) 366 458 .await 367 459 .unwrap();
+1
crates/opake-core/src/lib.rs
··· 26 26 pub mod documents; 27 27 pub mod error; 28 28 pub mod keyrings; 29 + pub mod metadata; 29 30 pub mod pairing; 30 31 pub mod paths; 31 32 pub mod records;
+12
crates/opake-core/src/metadata/mod.rs
··· 1 + // Document metadata operations: read, write, and name resolution. 2 + // 3 + // This module handles the orchestration of fetching a document record, 4 + // unwrapping the content key, decrypting metadata, optionally modifying 5 + // it, re-encrypting, and writing back. It sits above `crypto::metadata` 6 + // (pure encrypt/decrypt) and below the CLI/web layer. 7 + 8 + mod read; 9 + mod write; 10 + 11 + pub use read::fetch_document_metadata; 12 + pub use write::update_document_metadata;
+86
crates/opake-core/src/metadata/read.rs
··· 1 + use log::debug; 2 + 3 + use crate::atproto; 4 + use crate::client::{Transport, XrpcClient}; 5 + use crate::crypto::{self, ContentKey, DocumentMetadata, X25519PrivateKey}; 6 + use crate::error::Error; 7 + use crate::records::{self, Document, Encryption}; 8 + 9 + /// Result of reading and decrypting a document's metadata. 10 + pub struct DocumentMetadataResult { 11 + pub metadata: DocumentMetadata, 12 + pub content_key: ContentKey, 13 + pub document: Document, 14 + pub rkey: String, 15 + } 16 + 17 + /// Fetch a document record, unwrap the content key, and decrypt its metadata. 18 + /// 19 + /// For direct-encrypted documents, the caller's private key is used directly. 20 + /// For keyring-encrypted documents, `group_key` must be provided (from the 21 + /// local keyring cache). 22 + pub async fn fetch_document_metadata( 23 + client: &mut XrpcClient<impl Transport>, 24 + uri: &str, 25 + did: &str, 26 + private_key: &X25519PrivateKey, 27 + group_key: Option<&ContentKey>, 28 + ) -> Result<DocumentMetadataResult, Error> { 29 + let at_uri = atproto::parse_at_uri(uri)?; 30 + 31 + debug!("fetching document record {}", uri); 32 + let entry = client 33 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 34 + .await?; 35 + 36 + let doc: Document = serde_json::from_value(entry.value)?; 37 + records::check_version(doc.opake_version)?; 38 + 39 + let content_key = unwrap_content_key(&doc, did, private_key, group_key)?; 40 + 41 + let metadata = crypto::decrypt_metadata(&content_key, &doc.encrypted_metadata)?; 42 + 43 + Ok(DocumentMetadataResult { 44 + metadata, 45 + content_key, 46 + document: doc, 47 + rkey: at_uri.rkey, 48 + }) 49 + } 50 + 51 + /// Unwrap the content key from a document's encryption envelope. 52 + fn unwrap_content_key( 53 + doc: &Document, 54 + did: &str, 55 + private_key: &X25519PrivateKey, 56 + group_key: Option<&ContentKey>, 57 + ) -> Result<ContentKey, Error> { 58 + match &doc.encryption { 59 + Encryption::Direct(direct) => { 60 + let wrapped = direct 61 + .envelope 62 + .keys 63 + .iter() 64 + .find(|k| k.did == did) 65 + .ok_or_else(|| { 66 + Error::InvalidRecord(format!( 67 + "no wrapped key for DID ({did}) — you may not have access" 68 + )) 69 + })?; 70 + crypto::unwrap_key(wrapped, private_key) 71 + } 72 + Encryption::Keyring(kr_enc) => { 73 + let gk = group_key.ok_or_else(|| { 74 + Error::InvalidRecord( 75 + "document uses keyring encryption but no group key provided".into(), 76 + ) 77 + })?; 78 + let wrapped_bytes = kr_enc 79 + .keyring_ref 80 + .wrapped_content_key 81 + .decode() 82 + .map_err(|e| Error::InvalidRecord(format!("invalid wrapped content key: {e}")))?; 83 + crypto::unwrap_content_key_from_keyring(&wrapped_bytes, gk) 84 + } 85 + } 86 + }
+40
crates/opake-core/src/metadata/write.rs
··· 1 + use log::debug; 2 + 3 + use crate::client::{Transport, XrpcClient}; 4 + use crate::crypto::{self, ContentKey, CryptoRng, DocumentMetadata, RngCore, X25519PrivateKey}; 5 + use crate::documents::DOCUMENT_COLLECTION; 6 + use crate::error::Error; 7 + 8 + use super::read::fetch_document_metadata; 9 + 10 + /// Fetch a document, decrypt its metadata, apply a mutation, re-encrypt, 11 + /// and write the record back via `putRecord`. 12 + /// 13 + /// The `mutator` receives a mutable reference to the decrypted metadata 14 + /// and can modify any fields. After mutation, the metadata is re-encrypted 15 + /// with a fresh nonce and the record is updated on the PDS. 16 + pub async fn update_document_metadata( 17 + client: &mut XrpcClient<impl Transport>, 18 + uri: &str, 19 + did: &str, 20 + private_key: &X25519PrivateKey, 21 + group_key: Option<&ContentKey>, 22 + rng: &mut (impl CryptoRng + RngCore), 23 + mutator: impl FnOnce(&mut DocumentMetadata), 24 + ) -> Result<DocumentMetadata, Error> { 25 + let result = fetch_document_metadata(client, uri, did, private_key, group_key).await?; 26 + let mut metadata = result.metadata; 27 + let mut doc = result.document; 28 + 29 + mutator(&mut metadata); 30 + 31 + debug!("re-encrypting metadata for {}", uri); 32 + let encrypted = crypto::encrypt_metadata(&result.content_key, &metadata, rng)?; 33 + doc.encrypted_metadata = encrypted; 34 + 35 + client 36 + .put_record(DOCUMENT_COLLECTION, &result.rkey, &doc) 37 + .await?; 38 + 39 + Ok(metadata) 40 + }
+9
crates/opake-core/src/records/defs.rs
··· 27 27 pub wrapped_content_key: AtBytes, 28 28 pub rotation: u64, 29 29 } 30 + 31 + /// AES-256-GCM encrypted metadata payload. The ciphertext contains a JSON 32 + /// object with the real metadata (name, mimeType, size, tags, description). 33 + /// Encrypted with the same content key as the blob. 34 + #[derive(Debug, Clone, Serialize, Deserialize)] 35 + pub struct EncryptedMetadata { 36 + pub ciphertext: AtBytes, 37 + pub nonce: AtBytes, 38 + }
+10 -2
crates/opake-core/src/records/document.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 3 - use super::{default_version, EncryptionEnvelope, KeyringRef, SCHEMA_VERSION}; 3 + use super::{default_version, EncryptedMetadata, EncryptionEnvelope, KeyringRef, SCHEMA_VERSION}; 4 4 use crate::atproto::{AtBytes, BlobRef}; 5 5 6 6 /// Content key wrapped directly to individual DIDs. ··· 44 44 pub tags: Vec<String>, 45 45 #[serde(skip_serializing_if = "Option::is_none")] 46 46 pub description: Option<String>, 47 + pub encrypted_metadata: EncryptedMetadata, 47 48 #[serde(skip_serializing_if = "Option::is_none")] 48 49 pub visibility: Option<String>, 49 50 pub created_at: String, ··· 55 56 /// Construct a new document with the current schema version and sensible 56 57 /// defaults for optional fields. Callers set tags/description/etc. via 57 58 /// struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 58 - pub fn new(name: String, blob: BlobRef, encryption: Encryption, created_at: String) -> Self { 59 + pub fn new( 60 + name: String, 61 + blob: BlobRef, 62 + encryption: Encryption, 63 + encrypted_metadata: EncryptedMetadata, 64 + created_at: String, 65 + ) -> Self { 59 66 Self { 60 67 opake_version: SCHEMA_VERSION, 61 68 name, ··· 65 72 encryption, 66 73 tags: Vec::new(), 67 74 description: None, 75 + encrypted_metadata, 68 76 visibility: None, 69 77 created_at, 70 78 modified_at: None,
+1 -1
crates/opake-core/src/records/mod.rs
··· 22 22 pub use crate::atproto::{AtBytes, BlobRef, CidLink}; 23 23 24 24 // Re-export all record types at the `records::` level. 25 - pub use defs::{EncryptionEnvelope, KeyringRef, WrappedKey}; 25 + pub use defs::{EncryptedMetadata, EncryptionEnvelope, KeyringRef, WrappedKey}; 26 26 pub use directory::Directory; 27 27 pub use document::{DirectEncryption, Document, Encryption, KeyringEncryption}; 28 28 pub use grant::Grant;
+52 -1
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 - use opake_core::crypto::{ContentKey, EncryptedPayload, OsRng, X25519PrivateKey, X25519PublicKey}; 3 + use opake_core::crypto::{ 4 + ContentKey, DocumentMetadata, EncryptedPayload, OsRng, X25519PrivateKey, X25519PublicKey, 5 + }; 4 6 use opake_core::records::WrappedKey; 5 7 use opake_core::storage::Identity; 6 8 use serde::Serialize; ··· 196 198 }; 197 199 serde_wasm_bindgen::to_value(&dto).map_err(|e| JsError::new(&e.to_string())) 198 200 } 201 + 202 + // --------------------------------------------------------------------------- 203 + // Metadata encryption exports 204 + // --------------------------------------------------------------------------- 205 + 206 + /// Encrypt a metadata JS object with a content key. 207 + /// 208 + /// `metadata` must be a JS object matching `DocumentMetadata` 209 + /// (fields: name, mimeType?, size?, tags?, description?). 210 + /// Returns a JS object with `ciphertext` (Uint8Array) and `nonce` (Uint8Array). 211 + #[wasm_bindgen(js_name = encryptMetadata)] 212 + pub fn encrypt_metadata_js(key: &[u8], metadata: JsValue) -> Result<JsValue, JsError> { 213 + let content_key = content_key_from_slice(key)?; 214 + let metadata: DocumentMetadata = 215 + serde_wasm_bindgen::from_value(metadata).map_err(|e| JsError::new(&e.to_string()))?; 216 + let encrypted = opake_core::crypto::encrypt_metadata(&content_key, &metadata, &mut OsRng) 217 + .map_err(|e| JsError::new(&e.to_string()))?; 218 + 219 + let ciphertext = encrypted 220 + .ciphertext 221 + .decode() 222 + .map_err(|e| JsError::new(&e.to_string()))?; 223 + let nonce = encrypted 224 + .nonce 225 + .decode() 226 + .map_err(|e| JsError::new(&e.to_string()))?; 227 + 228 + let dto = EncryptedPayloadDto { ciphertext, nonce }; 229 + serde_wasm_bindgen::to_value(&dto).map_err(|e| JsError::new(&e.to_string())) 230 + } 231 + 232 + /// Decrypt encrypted metadata back to a JS object. 233 + /// 234 + /// Returns a JS object matching `DocumentMetadata`. 235 + #[wasm_bindgen(js_name = decryptMetadata)] 236 + pub fn decrypt_metadata_js( 237 + key: &[u8], 238 + ciphertext: &[u8], 239 + nonce: &[u8], 240 + ) -> Result<JsValue, JsError> { 241 + let content_key = content_key_from_slice(key)?; 242 + let encrypted = opake_core::records::EncryptedMetadata { 243 + ciphertext: opake_core::records::AtBytes::from_raw(ciphertext), 244 + nonce: opake_core::records::AtBytes::from_raw(nonce), 245 + }; 246 + let metadata = opake_core::crypto::decrypt_metadata(&content_key, &encrypted) 247 + .map_err(|e| JsError::new(&e.to_string()))?; 248 + serde_wasm_bindgen::to_value(&metadata).map_err(|e| JsError::new(&e.to_string())) 249 + }
+17
lexicons/app.opake.defs.json
··· 75 75 } 76 76 }, 77 77 78 + "encryptedMetadata": { 79 + "type": "object", 80 + "description": "AES-256-GCM encrypted metadata payload. The ciphertext contains a JSON object with the real document metadata (name, mimeType, size, tags, description). Encrypted with the same content key as the blob.", 81 + "required": ["ciphertext", "nonce"], 82 + "properties": { 83 + "ciphertext": { 84 + "type": "bytes", 85 + "description": "AES-256-GCM ciphertext of the JSON-serialized metadata object." 86 + }, 87 + "nonce": { 88 + "type": "bytes", 89 + "description": "12-byte initialization vector for AES-256-GCM.", 90 + "maxLength": 24 91 + } 92 + } 93 + }, 94 + 78 95 "visibility": { 79 96 "type": "string", 80 97 "description": "Hint for AppViews and clients about intended visibility of the content.",
+5 -1
lexicons/app.opake.document.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["opakeVersion", "name", "blob", "encryption", "createdAt"], 11 + "required": ["opakeVersion", "name", "blob", "encryption", "encryptedMetadata", "createdAt"], 12 12 "properties": { 13 13 "opakeVersion": { 14 14 "type": "integer", ··· 54 54 "type": "string", 55 55 "description": "Optional plaintext description or summary.", 56 56 "maxLength": 1024 57 + }, 58 + "encryptedMetadata": { 59 + "type": "ref", 60 + "ref": "app.opake.defs#encryptedMetadata" 57 61 }, 58 62 "visibility": { 59 63 "type": "ref",