An encrypted personal cloud built on the AT Protocol.

Encrypt keyring and grant metadata with AES-256-GCM

Apply the same encrypted metadata pattern from documents (#187) to
keyrings and grants. All advisory metadata (name, description on
keyrings; permissions, note on grants) is now encrypted client-side
and stored in an `encryptedMetadata` envelope. Plaintext fields are
removed entirely from lexicons, record structs, DB schema, and API
responses.

- Generic encrypt_metadata/decrypt_metadata over Serialize/DeserializeOwned
- KeyringMetadata encrypted with group key, GrantMetadata with content key
- encryptedMetadata is required on both record types
- remove_member re-encrypts metadata after key rotation
- WASM exports for keyring/grant metadata encrypt/decrypt
- AppView no longer stores or serves plaintext metadata fields

Closes #188

+502 -360
+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 keyring and grant metadata [#188](https://issues.opake.app/issues/188.html) 69 70 - Encrypt document metadata (name, mimeType, tags, description) [#187](https://issues.opake.app/issues/187.html) 70 71 - Rename collection NSIDs from app.opake.cloud.* to app.opake.* and version to opakeVersion [#186](https://issues.opake.app/issues/186.html) 71 72 - Add browser key storage with IndexedDB and Web Crypto API [#160](https://issues.opake.app/issues/160.html)
-10
crates/opake-appview/src/api/api_tests.rs
··· 126 126 owner_did: "did:plc:owner".into(), 127 127 recipient_did: "did:plc:me".into(), 128 128 document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 129 - permissions: Some("read".into()), 130 - note: Some("test file".into()), 131 129 created_at: "2026-03-01T12:00:00Z".into(), 132 130 indexed_at: "2026-03-01T12:00:01Z".into(), 133 131 }; ··· 147 145 items[0]["documentUri"], 148 146 "at://did:plc:owner/app.opake.document/3xyz" 149 147 ); 150 - assert_eq!(items[0]["permissions"], "read"); 151 - assert_eq!(items[0]["note"], "test file"); 152 148 } 153 149 154 150 #[tokio::test] ··· 161 157 owner_did: "did:plc:owner".into(), 162 158 recipient_did: "did:plc:me".into(), 163 159 document_uri: format!("at://did:plc:owner/app.opake.document/{i}"), 164 - permissions: None, 165 - note: None, 166 160 created_at: "2026-03-01T12:00:00Z".into(), 167 161 indexed_at: format!("2026-03-01T12:00:0{i}Z"), 168 162 }; ··· 199 193 c, 200 194 "at://did:plc:owner/app.opake.keyring/3def", 201 195 "did:plc:owner", 202 - "family-photos", 203 196 &["did:plc:me".into(), "did:plc:other".into()], 204 197 "2026-03-01T12:00:00Z", 205 198 ) ··· 213 206 let items = json["keyrings"].as_array().unwrap(); 214 207 assert_eq!(items.len(), 1); 215 208 assert_eq!(items[0]["ownerDid"], "did:plc:owner"); 216 - assert_eq!(items[0]["name"], "family-photos"); 217 209 } 218 210 219 211 #[tokio::test] ··· 225 217 owner_did: "did:plc:owner".into(), 226 218 recipient_did: "did:plc:me".into(), 227 219 document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 228 - permissions: None, 229 - note: None, 230 220 created_at: "2026-03-01T12:00:00Z".into(), 231 221 indexed_at: "2026-03-01T12:00:01Z".into(), 232 222 };
-6
crates/opake-appview/src/api/types.rs
··· 17 17 pub uri: String, 18 18 pub owner_did: String, 19 19 pub document_uri: String, 20 - pub permissions: Option<String>, 21 - pub note: Option<String>, 22 20 pub created_at: String, 23 21 } 24 22 ··· 28 26 uri: g.uri.clone(), 29 27 owner_did: g.owner_did.clone(), 30 28 document_uri: g.document_uri.clone(), 31 - permissions: g.permissions.clone(), 32 - note: g.note.clone(), 33 29 created_at: g.created_at.clone(), 34 30 } 35 31 } ··· 48 44 pub struct KeyringItem { 49 45 pub uri: String, 50 46 pub owner_did: String, 51 - pub name: String, 52 47 pub indexed_at: String, 53 48 } 54 49 ··· 57 52 Self { 58 53 uri: m.keyring_uri.clone(), 59 54 owner_did: m.owner_did.clone(), 60 - name: m.keyring_name.clone(), 61 55 indexed_at: m.indexed_at.clone(), 62 56 } 63 57 }
+5 -11
crates/opake-appview/src/db/db_tests.rs
··· 11 11 owner_did: owner.into(), 12 12 recipient_did: recipient.into(), 13 13 document_uri: doc_uri.into(), 14 - permissions: Some("read".into()), 15 - note: None, 16 14 created_at: "2026-03-01T12:00:00Z".into(), 17 15 indexed_at: "2026-03-01T12:00:01Z".into(), 18 16 } ··· 49 47 ); 50 48 db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 51 49 52 - grant.note = Some("updated note".into()); 50 + grant.document_uri = "at://did:plc:owner/app.opake.document/updated".into(); 53 51 db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 54 52 55 53 let inbox = db 56 54 .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None)) 57 55 .unwrap(); 58 56 assert_eq!(inbox.len(), 1); 59 - assert_eq!(inbox[0].note.as_deref(), Some("updated note")); 57 + assert_eq!( 58 + inbox[0].document_uri, 59 + "at://did:plc:owner/app.opake.document/updated" 60 + ); 60 61 } 61 62 62 63 #[test] ··· 88 89 owner_did: "did:plc:owner".into(), 89 90 recipient_did: "did:plc:me".into(), 90 91 document_uri: format!("at://did:plc:owner/app.opake.document/{i}"), 91 - permissions: None, 92 - note: None, 93 92 created_at: "2026-03-01T12:00:00Z".into(), 94 93 indexed_at: format!("2026-03-01T12:00:0{i}Z"), 95 94 }; ··· 123 122 c, 124 123 "at://did:plc:owner/app.opake.keyring/3def", 125 124 "did:plc:owner", 126 - "family-photos", 127 125 &members, 128 126 "2026-03-01T12:00:00Z", 129 127 ) ··· 134 132 .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None)) 135 133 .unwrap(); 136 134 assert_eq!(alice_keyrings.len(), 1); 137 - assert_eq!(alice_keyrings[0].keyring_name, "family-photos"); 138 135 139 136 let bob_keyrings = db 140 137 .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:bob", 50, None)) ··· 159 156 c, 160 157 uri, 161 158 "did:plc:owner", 162 - "family-photos", 163 159 &["did:plc:alice".into(), "did:plc:bob".into()], 164 160 "2026-03-01T12:00:00Z", 165 161 ) ··· 172 168 c, 173 169 uri, 174 170 "did:plc:owner", 175 - "family-photos", 176 171 &["did:plc:alice".into(), "did:plc:charlie".into()], 177 172 "2026-03-01T13:00:00Z", 178 173 ) ··· 202 197 c, 203 198 uri, 204 199 "did:plc:owner", 205 - "family-photos", 206 200 &["did:plc:alice".into()], 207 201 "2026-03-01T12:00:00Z", 208 202 )
+9 -16
crates/opake-appview/src/db/grants.rs
··· 9 9 pub owner_did: String, 10 10 pub recipient_did: String, 11 11 pub document_uri: String, 12 - pub permissions: Option<String>, 13 - pub note: Option<String>, 14 12 pub created_at: String, 15 13 pub indexed_at: String, 16 14 } 17 15 18 16 pub fn upsert_grant(conn: &Connection, grant: &IndexedGrant) -> Result<()> { 19 17 conn.execute( 20 - "INSERT INTO grants (uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at) 21 - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) 18 + "INSERT INTO grants (uri, owner_did, recipient_did, document_uri, created_at, indexed_at) 19 + VALUES (?1, ?2, ?3, ?4, ?5, ?6) 22 20 ON CONFLICT(uri) DO UPDATE SET 23 21 owner_did = excluded.owner_did, 24 22 recipient_did = excluded.recipient_did, 25 23 document_uri = excluded.document_uri, 26 - permissions = excluded.permissions, 27 - note = excluded.note, 28 24 created_at = excluded.created_at, 29 25 indexed_at = excluded.indexed_at", 30 26 params![ ··· 32 28 grant.owner_did, 33 29 grant.recipient_did, 34 30 grant.document_uri, 35 - grant.permissions, 36 - grant.note, 37 31 grant.created_at, 38 32 grant.indexed_at, 39 33 ], ··· 59 53 if let Some(cursor) = cursor { 60 54 let (cursor_time, cursor_uri) = parse_cursor(cursor); 61 55 let mut stmt = conn.prepare( 62 - "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 56 + "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at 63 57 FROM grants 64 58 WHERE recipient_did = ?1 65 59 AND (indexed_at < ?2 OR (indexed_at = ?2 AND uri < ?3)) ··· 75 69 } 76 70 } else { 77 71 let mut stmt = conn.prepare( 78 - "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 72 + "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at 79 73 FROM grants 80 74 WHERE recipient_did = ?1 81 75 ORDER BY indexed_at DESC, uri DESC ··· 91 85 } 92 86 93 87 /// List grants created by an owner DID, newest first. 88 + #[allow(dead_code)] 94 89 pub fn list_grants_by_owner( 95 90 conn: &Connection, 96 91 owner_did: &str, ··· 102 97 if let Some(cursor) = cursor { 103 98 let (cursor_time, cursor_uri) = parse_cursor(cursor); 104 99 let mut stmt = conn.prepare( 105 - "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 100 + "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at 106 101 FROM grants 107 102 WHERE owner_did = ?1 108 103 AND (indexed_at < ?2 OR (indexed_at = ?2 AND uri < ?3)) ··· 118 113 } 119 114 } else { 120 115 let mut stmt = conn.prepare( 121 - "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 116 + "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at 122 117 FROM grants 123 118 WHERE owner_did = ?1 124 119 ORDER BY indexed_at DESC, uri DESC ··· 139 134 owner_did: row.get(1)?, 140 135 recipient_did: row.get(2)?, 141 136 document_uri: row.get(3)?, 142 - permissions: row.get(4)?, 143 - note: row.get(5)?, 144 - created_at: row.get(6)?, 145 - indexed_at: row.get(7)?, 137 + created_at: row.get(4)?, 138 + indexed_at: row.get(5)?, 146 139 }) 147 140 } 148 141
+7 -15
crates/opake-appview/src/db/keyrings.rs
··· 4 4 5 5 /// A keyring membership row as stored in the index. 6 6 #[derive(Debug, Clone)] 7 + #[allow(dead_code)] 7 8 pub struct IndexedKeyringMember { 8 9 pub keyring_uri: String, 9 10 pub member_did: String, 10 11 pub owner_did: String, 11 - pub keyring_name: String, 12 12 pub indexed_at: String, 13 13 } 14 14 ··· 19 19 conn: &Connection, 20 20 keyring_uri: &str, 21 21 owner_did: &str, 22 - keyring_name: &str, 23 22 member_dids: &[String], 24 23 indexed_at: &str, 25 24 ) -> Result<()> { ··· 29 28 )?; 30 29 31 30 let mut stmt = conn.prepare( 32 - "INSERT INTO keyring_members (keyring_uri, member_did, owner_did, keyring_name, indexed_at) 33 - VALUES (?1, ?2, ?3, ?4, ?5)", 31 + "INSERT INTO keyring_members (keyring_uri, member_did, owner_did, indexed_at) 32 + VALUES (?1, ?2, ?3, ?4)", 34 33 )?; 35 34 36 35 for did in member_dids { 37 - stmt.execute(params![ 38 - keyring_uri, 39 - did, 40 - owner_did, 41 - keyring_name, 42 - indexed_at 43 - ])?; 36 + stmt.execute(params![keyring_uri, did, owner_did, indexed_at])?; 44 37 } 45 38 46 39 Ok(()) ··· 68 61 if let Some(cursor) = cursor { 69 62 let (cursor_time, cursor_uri) = parse_cursor(cursor); 70 63 let mut stmt = conn.prepare( 71 - "SELECT keyring_uri, member_did, owner_did, keyring_name, indexed_at 64 + "SELECT keyring_uri, member_did, owner_did, indexed_at 72 65 FROM keyring_members 73 66 WHERE member_did = ?1 74 67 AND (indexed_at < ?2 OR (indexed_at = ?2 AND keyring_uri < ?3)) ··· 84 77 } 85 78 } else { 86 79 let mut stmt = conn.prepare( 87 - "SELECT keyring_uri, member_did, owner_did, keyring_name, indexed_at 80 + "SELECT keyring_uri, member_did, owner_did, indexed_at 88 81 FROM keyring_members 89 82 WHERE member_did = ?1 90 83 ORDER BY indexed_at DESC, keyring_uri DESC ··· 104 97 keyring_uri: row.get(0)?, 105 98 member_did: row.get(1)?, 106 99 owner_did: row.get(2)?, 107 - keyring_name: row.get(3)?, 108 - indexed_at: row.get(4)?, 100 + indexed_at: row.get(3)?, 109 101 }) 110 102 } 111 103
-3
crates/opake-appview/src/db/schema.rs
··· 11 11 owner_did TEXT NOT NULL, 12 12 recipient_did TEXT NOT NULL, 13 13 document_uri TEXT NOT NULL, 14 - permissions TEXT, 15 - note TEXT, 16 14 created_at TEXT NOT NULL, 17 15 indexed_at TEXT NOT NULL 18 16 ); ··· 23 21 keyring_uri TEXT NOT NULL, 24 22 member_did TEXT NOT NULL, 25 23 owner_did TEXT NOT NULL, 26 - keyring_name TEXT NOT NULL, 27 24 indexed_at TEXT NOT NULL, 28 25 PRIMARY KEY (keyring_uri, member_did) 29 26 );
-6
crates/opake-appview/src/firehose/events.rs
··· 31 31 owner_did: String, 32 32 recipient_did: String, 33 33 document_uri: String, 34 - permissions: Option<String>, 35 - note: Option<String>, 36 34 created_at: String, 37 35 }, 38 36 DeleteGrant { ··· 41 39 UpsertKeyring { 42 40 uri: String, 43 41 owner_did: String, 44 - name: String, 45 42 member_dids: Vec<String>, 46 43 }, 47 44 DeleteKeyring { ··· 71 68 owner_did: event.did, 72 69 recipient_did: grant.recipient, 73 70 document_uri: grant.document, 74 - permissions: grant.permissions, 75 - note: grant.note, 76 71 created_at: grant.created_at, 77 72 }, 78 73 event.time_us, ··· 88 83 IndexableEvent::UpsertKeyring { 89 84 uri, 90 85 owner_did: event.did, 91 - name: keyring.name, 92 86 member_dids, 93 87 }, 94 88 event.time_us,
+8 -3
crates/opake-appview/src/firehose/events_tests.rs
··· 20 20 "ciphertext": {{ "$bytes": "AAAA" }}, 21 21 "algo": "x25519-hkdf-a256kw" 22 22 }}, 23 + "encryptedMetadata": {{ 24 + "ciphertext": {{ "$bytes": "AAAA" }}, 25 + "nonce": {{ "$bytes": "AAAAAAAAAAAAAAAA" }} 26 + }}, 23 27 "createdAt": "2026-03-01T12:00:00Z" 24 28 }}, 25 29 "cid": "bafyabc" ··· 41 45 "rkey": "3def", 42 46 "record": {{ 43 47 "opakeVersion": 1, 44 - "name": "family-photos", 45 48 "algo": "aes-256-gcm", 46 49 "members": [ 47 50 {{ ··· 56 59 }} 57 60 ], 58 61 "rotation": 0, 62 + "encryptedMetadata": {{ 63 + "ciphertext": {{ "$bytes": "AAAA" }}, 64 + "nonce": {{ "$bytes": "AAAAAAAAAAAAAAAA" }} 65 + }}, 59 66 "createdAt": "2026-03-01T12:00:00Z" 60 67 }}, 61 68 "cid": "bafydef" ··· 135 142 IndexableEvent::UpsertKeyring { 136 143 uri, 137 144 owner_did, 138 - name, 139 145 member_dids, 140 146 } => { 141 147 assert_eq!(uri, "at://did:plc:owner123/app.opake.keyring/3def"); 142 148 assert_eq!(owner_did, "did:plc:owner123"); 143 - assert_eq!(name, "family-photos"); 144 149 assert_eq!(member_dids, vec!["did:plc:alice", "did:plc:bob"]); 145 150 } 146 151 other => panic!("expected UpsertKeyring, got {other:?}"),
+1 -6
crates/opake-appview/src/indexer.rs
··· 85 85 owner_did, 86 86 recipient_did, 87 87 document_uri, 88 - permissions, 89 - note, 90 88 created_at, 91 89 } => { 92 90 let grant = IndexedGrant { ··· 94 92 owner_did: owner_did.clone(), 95 93 recipient_did: recipient_did.clone(), 96 94 document_uri: document_uri.clone(), 97 - permissions: permissions.clone(), 98 - note: note.clone(), 99 95 created_at: created_at.clone(), 100 96 indexed_at: now.clone(), 101 97 }; ··· 111 107 IndexableEvent::UpsertKeyring { 112 108 uri, 113 109 owner_did, 114 - name, 115 110 member_dids, 116 111 } => { 117 - keyrings::upsert_keyring_members(conn, uri, owner_did, name, member_dids, &now)?; 112 + keyrings::upsert_keyring_members(conn, uri, owner_did, member_dids, &now)?; 118 113 log::debug!("indexed keyring: {uri} ({} members)", member_dids.len()); 119 114 Ok(()) 120 115 }
-10
crates/opake-appview/src/indexer_tests.rs
··· 18 18 owner_did: "did:plc:owner".into(), 19 19 recipient_did: "did:plc:recipient".into(), 20 20 document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 21 - permissions: Some("read".into()), 22 - note: Some("shared file".into()), 23 21 created_at: "2026-03-01T12:00:00Z".into(), 24 22 }; 25 23 process_event(&state, &event, 1709330400000000).unwrap(); ··· 30 28 .unwrap(); 31 29 assert_eq!(inbox.len(), 1); 32 30 assert_eq!(inbox[0].owner_did, "did:plc:owner"); 33 - assert_eq!(inbox[0].note.as_deref(), Some("shared file")); 34 31 } 35 32 36 33 #[test] ··· 43 40 owner_did: "did:plc:owner".into(), 44 41 recipient_did: "did:plc:recipient".into(), 45 42 document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 46 - permissions: None, 47 - note: None, 48 43 created_at: "2026-03-01T12:00:00Z".into(), 49 44 }; 50 45 process_event(&state, &create, 1709330400000000).unwrap(); ··· 65 60 let event = IndexableEvent::UpsertKeyring { 66 61 uri: "at://did:plc:owner/app.opake.keyring/3def".into(), 67 62 owner_did: "did:plc:owner".into(), 68 - name: "family-photos".into(), 69 63 member_dids: vec!["did:plc:alice".into(), "did:plc:bob".into()], 70 64 }; 71 65 process_event(&state, &event, 1709330400000000).unwrap(); ··· 75 69 .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None)) 76 70 .unwrap(); 77 71 assert_eq!(alice.len(), 1); 78 - assert_eq!(alice[0].keyring_name, "family-photos"); 79 72 80 73 let bob = state 81 74 .db ··· 92 85 let create = IndexableEvent::UpsertKeyring { 93 86 uri: uri.into(), 94 87 owner_did: "did:plc:owner".into(), 95 - name: "family-photos".into(), 96 88 member_dids: vec!["did:plc:alice".into(), "did:plc:bob".into()], 97 89 }; 98 90 process_event(&state, &create, 1709330400000000).unwrap(); ··· 101 93 let update = IndexableEvent::UpsertKeyring { 102 94 uri: uri.into(), 103 95 owner_did: "did:plc:owner".into(), 104 - name: "family-photos".into(), 105 96 member_dids: vec!["did:plc:alice".into(), "did:plc:charlie".into()], 106 97 }; 107 98 process_event(&state, &update, 1709330500000000).unwrap(); ··· 127 118 let create = IndexableEvent::UpsertKeyring { 128 119 uri: uri.into(), 129 120 owner_did: "did:plc:owner".into(), 130 - name: "family-photos".into(), 131 121 member_dids: vec!["did:plc:alice".into()], 132 122 }; 133 123 process_event(&state, &create, 1709330400000000).unwrap();
+7 -46
crates/opake-cli/src/commands/inbox.rs
··· 22 22 fn format_short(grants: &[InboxGrant]) -> String { 23 23 grants 24 24 .iter() 25 - .map(|g| { 26 - let perms = g.permissions.as_deref().unwrap_or("—"); 27 - format!("{}\t{}\t{}", g.owner_did, perms, g.uri) 28 - }) 25 + .map(|g| format!("{}\t{}", g.owner_did, g.uri)) 29 26 .collect::<Vec<_>>() 30 27 .join("\n") 31 28 } ··· 34 31 grants 35 32 .iter() 36 33 .map(|g| { 37 - let perms = g.permissions.as_deref().unwrap_or("—"); 38 - let note = g 39 - .note 40 - .as_deref() 41 - .map(|n| format!("\n note: {n}")) 42 - .unwrap_or_default(); 43 34 format!( 44 - "{:>10} {} {}\n doc: {}\n grant: {}{}", 45 - perms, g.created_at, g.owner_did, g.document_uri, g.uri, note, 35 + " {} {}\n doc: {}\n grant: {}", 36 + g.created_at, g.owner_did, g.document_uri, g.uri, 46 37 ) 47 38 }) 48 39 .collect::<Vec<_>>() ··· 83 74 mod tests { 84 75 use super::*; 85 76 86 - fn grant(owner: &str, doc_suffix: &str, perms: Option<&str>, note: Option<&str>) -> InboxGrant { 77 + fn grant(owner: &str, doc_suffix: &str) -> InboxGrant { 87 78 InboxGrant { 88 79 uri: "at://did:plc:owner/app.opake.grant/g1".into(), 89 80 owner_did: owner.into(), 90 81 document_uri: format!("at://did:plc:owner/app.opake.document/{doc_suffix}"), 91 - permissions: perms.map(|s| s.into()), 92 - note: note.map(|s| s.into()), 93 82 created_at: "2026-03-01T12:00:00Z".into(), 94 83 } 95 84 } 96 85 97 86 #[test] 98 87 fn short_format() { 99 - let grants = vec![grant("did:plc:alice", "doc1", Some("read"), None)]; 88 + let grants = vec![grant("did:plc:alice", "doc1")]; 100 89 let output = format_short(&grants); 101 90 assert!(output.contains("did:plc:alice")); 102 - assert!(output.contains("read")); 103 91 assert!(output.contains("grant/g1")); 104 92 } 105 93 106 94 #[test] 107 - fn short_format_missing_permissions() { 108 - let grants = vec![grant("did:plc:alice", "doc1", None, None)]; 109 - let output = format_short(&grants); 110 - assert!(output.contains('—')); 111 - } 112 - 113 - #[test] 114 - fn long_format_with_note() { 115 - let grants = vec![grant( 116 - "did:plc:alice", 117 - "doc1", 118 - Some("read"), 119 - Some("tax docs"), 120 - )]; 95 + fn long_format() { 96 + let grants = vec![grant("did:plc:alice", "doc1")]; 121 97 let output = format_long(&grants); 122 98 assert!(output.contains("did:plc:alice")); 123 99 assert!(output.contains("doc: at://")); 124 100 assert!(output.contains("grant: at://")); 125 - assert!(output.contains("note: tax docs")); 126 - } 127 - 128 - #[test] 129 - fn long_format_no_note() { 130 - let grants = vec![grant("did:plc:alice", "doc1", Some("read"), None)]; 131 - let output = format_long(&grants); 132 - assert!(!output.contains("note:")); 133 - } 134 - 135 - #[test] 136 - fn long_format_missing_permissions_shows_em_dash() { 137 - let grants = vec![grant("did:plc:alice", "doc1", None, None)]; 138 - let output = format_long(&grants); 139 - assert!(output.contains('—')); 140 101 } 141 102 }
+20 -7
crates/opake-cli/src/commands/keyring.rs
··· 82 82 83 83 let params = CreateKeyringParams { 84 84 name: &args.name, 85 + description: None, 85 86 owner_did: &id.did, 86 87 owner_public_key: &owner_pubkey, 87 88 created_at: &Utc::now().to_rfc3339(), ··· 98 99 99 100 async fn ls(ctx: &CommandContext, args: LsArgs) -> Result<Option<Session>> { 100 101 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 102 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 103 + let private_key = id.private_key_bytes()?; 101 104 let entries = keyrings::list_keyrings(&mut client).await?; 102 105 103 106 if entries.is_empty() { ··· 106 109 } 107 110 108 111 for entry in &entries { 112 + let name = keyrings::decrypt_keyring_name(entry, &id.did, &private_key) 113 + .unwrap_or_else(|| "<encrypted>".into()); 114 + 109 115 if args.long { 110 116 println!( 111 117 "{}\t{} member(s)\trotation:{}\t{}", 112 - entry.name, entry.member_count, entry.rotation, entry.uri, 118 + name, entry.member_count, entry.rotation, entry.uri, 113 119 ); 114 120 } else { 115 - println!("{}\t{} member(s)", entry.name, entry.member_count); 121 + println!("{}\t{} member(s)", name, entry.member_count); 116 122 } 117 123 } 118 124 ··· 122 128 123 129 async fn add_member(ctx: &CommandContext, args: AddMemberArgs) -> Result<Option<Session>> { 124 130 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 131 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 132 + let private_key = id.private_key_bytes()?; 125 133 126 - let entry = keyrings::resolve_keyring_uri(&mut client, &args.keyring).await?; 134 + let entry = 135 + keyrings::resolve_keyring_uri(&mut client, &args.keyring, &id.did, &private_key).await?; 127 136 let at_uri = atproto::parse_at_uri(&entry.uri)?; 128 137 129 138 let group_key = ··· 150 159 151 160 async fn remove_member(ctx: &CommandContext, args: RemoveMemberArgs) -> Result<Option<Session>> { 152 161 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 162 + let id = identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 163 + let private_key = id.private_key_bytes()?; 153 164 154 - let entry = keyrings::resolve_keyring_uri(&mut client, &args.keyring).await?; 165 + let entry = 166 + keyrings::resolve_keyring_uri(&mut client, &args.keyring, &id.did, &private_key).await?; 155 167 let at_uri = atproto::parse_at_uri(&entry.uri)?; 156 168 169 + let old_group_key = 170 + keyring_store::load_group_key(&ctx.storage, &ctx.did, &at_uri.rkey, entry.rotation)?; 171 + 157 172 // Resolve the member to remove — we need their DID 158 173 let transport = ReqwestTransport::new(); 159 174 let resolved = resolve::resolve_identity(&transport, &ctx.pds_url, &args.member).await?; 160 175 let display = resolved.handle.as_deref().unwrap_or(&resolved.did); 161 176 162 177 if !args.yes { 163 - let id = 164 - identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 165 - 166 178 // Check they're actually a member before prompting 167 179 let kr_record = client 168 180 .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) ··· 226 238 &entry.uri, 227 239 &resolved.did, 228 240 &remaining_keys, 241 + &old_group_key, 229 242 &Utc::now().to_rfc3339(), 230 243 &mut OsRng, 231 244 )
+18 -36
crates/opake-cli/src/commands/shared.rs
··· 17 17 fn format_short(entries: &[GrantEntry]) -> String { 18 18 entries 19 19 .iter() 20 - .map(|e| { 21 - let perms = e.permissions.as_deref().unwrap_or("—"); 22 - format!("{}\t{}\t{}", e.recipient, perms, e.uri) 23 - }) 20 + .map(|e| format!("{}\t{}", e.recipient, e.uri)) 24 21 .collect::<Vec<_>>() 25 22 .join("\n") 26 23 } ··· 29 26 entries 30 27 .iter() 31 28 .map(|e| { 32 - let perms = e.permissions.as_deref().unwrap_or("—"); 33 - let note = e 34 - .note 35 - .as_deref() 36 - .map(|n| format!("\n note: {n}")) 37 - .unwrap_or_default(); 38 29 format!( 39 - "{:>10} {} {}\n doc: {}\n grant: {}{}", 40 - perms, e.created_at, e.recipient, e.document, e.uri, note, 30 + " {} {}\n doc: {}\n grant: {}", 31 + e.created_at, e.recipient, e.document, e.uri, 41 32 ) 42 33 }) 43 34 .collect::<Vec<_>>() ··· 70 61 mod tests { 71 62 use super::*; 72 63 73 - fn entry(recipient: &str, doc: &str, perms: Option<&str>, note: Option<&str>) -> GrantEntry { 64 + fn dummy_encrypted_metadata() -> opake_core::records::EncryptedMetadata { 65 + opake_core::records::EncryptedMetadata { 66 + ciphertext: opake_core::records::AtBytes { 67 + encoded: "AAAA".into(), 68 + }, 69 + nonce: opake_core::records::AtBytes { 70 + encoded: "BBBB".into(), 71 + }, 72 + } 73 + } 74 + 75 + fn entry(recipient: &str, doc: &str) -> GrantEntry { 74 76 GrantEntry { 75 77 uri: format!("at://did:plc:owner/app.opake.grant/g1"), 76 78 document: doc.into(), 77 79 recipient: recipient.into(), 78 - permissions: perms.map(|s| s.into()), 79 - note: note.map(|s| s.into()), 80 + encrypted_metadata: dummy_encrypted_metadata(), 81 + expires_at: None, 80 82 created_at: "2026-03-01T12:00:00Z".into(), 81 83 } 82 84 } ··· 86 88 let entries = vec![entry( 87 89 "did:plc:bob", 88 90 "at://did:plc:owner/app.opake.document/doc1", 89 - Some("read"), 90 - None, 91 91 )]; 92 92 let output = format_short(&entries); 93 93 assert!(output.contains("did:plc:bob")); 94 - assert!(output.contains("read")); 95 94 assert!(output.contains("grant/g1")); 96 95 } 97 96 98 97 #[test] 99 - fn short_format_missing_permissions() { 100 - let entries = vec![entry("did:plc:bob", "at://doc", None, None)]; 101 - let output = format_short(&entries); 102 - assert!(output.contains('—')); 103 - } 104 - 105 - #[test] 106 - fn long_format_with_note() { 98 + fn long_format() { 107 99 let entries = vec![entry( 108 100 "did:plc:bob", 109 101 "at://did:plc:owner/app.opake.document/doc1", 110 - Some("read"), 111 - Some("tax doc"), 112 102 )]; 113 103 let output = format_long(&entries); 114 104 assert!(output.contains("did:plc:bob")); 115 105 assert!(output.contains("doc: at://")); 116 106 assert!(output.contains("grant: at://")); 117 - assert!(output.contains("note: tax doc")); 118 - } 119 - 120 - #[test] 121 - fn long_format_no_note() { 122 - let entries = vec![entry("did:plc:bob", "at://doc", Some("read"), None)]; 123 - let output = format_long(&entries); 124 - assert!(!output.contains("note:")); 125 107 } 126 108 }
+5 -1
crates/opake-cli/src/commands/upload.rs
··· 56 56 let now = Utc::now().to_rfc3339(); 57 57 58 58 let uri = if let Some(keyring_name) = &self.keyring { 59 - let entry = keyrings::resolve_keyring_uri(&mut client, keyring_name).await?; 59 + let id = identity::load_identity(&ctx.storage, &ctx.did)?; 60 + let private_key = id.private_key_bytes()?; 61 + let entry = 62 + keyrings::resolve_keyring_uri(&mut client, keyring_name, &id.did, &private_key) 63 + .await?; 60 64 let at_uri = atproto::parse_at_uri(&entry.uri)?; 61 65 let group_key = keyring_store::load_group_key( 62 66 &ctx.storage,
+8 -2
crates/opake-cli/src/document_resolve.rs
··· 76 76 } 77 77 }; 78 78 79 - match crypto::decrypt_metadata(&content_key, &entry.encrypted_metadata) { 79 + match crypto::decrypt_metadata::<crypto::DocumentMetadata>( 80 + &content_key, 81 + &entry.encrypted_metadata, 82 + ) { 80 83 Ok(metadata) => metadata.name, 81 84 Err(e) => { 82 85 warn!("metadata decryption failed for {}: {e}", entry.uri); ··· 103 106 } 104 107 }; 105 108 106 - match crypto::decrypt_metadata(&content_key, &entry.encrypted_metadata) { 109 + match crypto::decrypt_metadata::<crypto::DocumentMetadata>( 110 + &content_key, 111 + &entry.encrypted_metadata, 112 + ) { 107 113 Ok(metadata) => { 108 114 entry.name = metadata.name; 109 115 entry.mime_type = metadata.mime_type;
-26
crates/opake-core/src/client/appview_types.rs
··· 11 11 pub uri: String, 12 12 pub owner_did: String, 13 13 pub document_uri: String, 14 - pub permissions: Option<String>, 15 - pub note: Option<String>, 16 14 pub created_at: String, 17 15 } 18 16 ··· 33 31 "uri": "at://did:plc:owner/app.opake.grant/tid1", 34 32 "ownerDid": "did:plc:owner", 35 33 "documentUri": "at://did:plc:owner/app.opake.document/doc1", 36 - "permissions": "read", 37 - "note": "tax docs", 38 34 "createdAt": "2026-03-01T12:00:00Z" 39 35 }], 40 36 "cursor": "next-page" ··· 47 43 resp.grants[0].document_uri, 48 44 "at://did:plc:owner/app.opake.document/doc1" 49 45 ); 50 - assert_eq!(resp.grants[0].permissions.as_deref(), Some("read")); 51 - assert_eq!(resp.grants[0].note.as_deref(), Some("tax docs")); 52 46 assert_eq!(resp.cursor.as_deref(), Some("next-page")); 53 47 } 54 48 ··· 57 51 let json = r#"{"grants": []}"#; 58 52 let resp: InboxResponse = serde_json::from_str(json).unwrap(); 59 53 assert!(resp.grants.is_empty()); 60 - assert!(resp.cursor.is_none()); 61 - } 62 - 63 - #[test] 64 - fn deserialize_null_optionals() { 65 - let json = r#"{ 66 - "grants": [{ 67 - "uri": "at://did:plc:owner/app.opake.grant/tid1", 68 - "ownerDid": "did:plc:owner", 69 - "documentUri": "at://did:plc:owner/app.opake.document/doc1", 70 - "permissions": null, 71 - "note": null, 72 - "createdAt": "2026-03-01T12:00:00Z" 73 - }], 74 - "cursor": null 75 - }"#; 76 - 77 - let resp: InboxResponse = serde_json::from_str(json).unwrap(); 78 - assert!(resp.grants[0].permissions.is_none()); 79 - assert!(resp.grants[0].note.is_none()); 80 54 assert!(resp.cursor.is_none()); 81 55 } 82 56 }
+43 -11
crates/opake-core/src/crypto/metadata.rs
··· 3 3 Aes256Gcm, Key, Nonce, 4 4 }; 5 5 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 6 - use serde::{Deserialize, Serialize}; 6 + use serde::{de::DeserializeOwned, Deserialize, Serialize}; 7 7 8 8 use super::{ContentKey, CryptoRng, RngCore}; 9 9 use crate::error::Error; 10 10 use crate::records::{AtBytes, EncryptedMetadata}; 11 11 12 - /// The plaintext metadata that gets encrypted inside `encryptedMetadata`. 13 - /// Serialized to JSON before encryption. 12 + // --------------------------------------------------------------------------- 13 + // Metadata types — one per record kind 14 + // --------------------------------------------------------------------------- 15 + 16 + /// Plaintext document metadata encrypted inside `encryptedMetadata`. 14 17 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 15 18 #[serde(rename_all = "camelCase")] 16 19 pub struct DocumentMetadata { ··· 25 28 pub description: Option<String>, 26 29 } 27 30 28 - /// Encrypt document metadata with the same content key used for the blob. 31 + /// Plaintext keyring metadata. Encrypted with the keyring's group key so only 32 + /// members can see the name/description. 33 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 34 + #[serde(rename_all = "camelCase")] 35 + pub struct KeyringMetadata { 36 + pub name: String, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub description: Option<String>, 39 + } 40 + 41 + /// Plaintext grant metadata. Encrypted with the document's content key so both 42 + /// grantor and recipient can read it. 43 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 44 + #[serde(rename_all = "camelCase")] 45 + pub struct GrantMetadata { 46 + #[serde(skip_serializing_if = "Option::is_none")] 47 + pub permissions: Option<String>, 48 + #[serde(skip_serializing_if = "Option::is_none")] 49 + pub note: Option<String>, 50 + } 51 + 52 + // --------------------------------------------------------------------------- 53 + // Generic encrypt / decrypt 54 + // --------------------------------------------------------------------------- 55 + 56 + /// Encrypt a metadata value with AES-256-GCM using a fresh nonce. 29 57 /// 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( 58 + /// Works for any `Serialize` type — the value is JSON-serialized before 59 + /// encryption. Use the same symmetric key that protects the parent record 60 + /// (content key for documents/grants, group key for keyrings). 61 + pub fn encrypt_metadata<T: Serialize>( 33 62 key: &ContentKey, 34 - metadata: &DocumentMetadata, 63 + metadata: &T, 35 64 rng: &mut (impl CryptoRng + RngCore), 36 65 ) -> Result<EncryptedMetadata, Error> { 37 66 let plaintext = serde_json::to_vec(metadata) ··· 53 82 }) 54 83 } 55 84 56 - /// Decrypt an `EncryptedMetadata` payload back to `DocumentMetadata`. 57 - pub fn decrypt_metadata( 85 + /// Decrypt an `EncryptedMetadata` payload back to `T`. 86 + /// 87 + /// Callers specify the expected type at the call site, e.g. 88 + /// `decrypt_metadata::<DocumentMetadata>(key, encrypted)`. 89 + pub fn decrypt_metadata<T: DeserializeOwned>( 58 90 key: &ContentKey, 59 91 encrypted: &EncryptedMetadata, 60 - ) -> Result<DocumentMetadata, Error> { 92 + ) -> Result<T, Error> { 61 93 let ciphertext = encrypted 62 94 .ciphertext 63 95 .decode()
+66 -3
crates/opake-core/src/crypto/metadata_tests.rs
··· 17 17 let metadata = sample_metadata(); 18 18 19 19 let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 20 - let decrypted = decrypt_metadata(&key, &encrypted).unwrap(); 20 + let decrypted: DocumentMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 21 21 22 22 assert_eq!(decrypted, metadata); 23 23 } ··· 29 29 let metadata = sample_metadata(); 30 30 31 31 let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 32 - let err = decrypt_metadata(&wrong_key, &encrypted).unwrap_err(); 32 + let err = decrypt_metadata::<DocumentMetadata>(&wrong_key, &encrypted).unwrap_err(); 33 33 34 34 assert!( 35 35 err.to_string().contains("aead"), ··· 49 49 }; 50 50 51 51 let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 52 - let decrypted = decrypt_metadata(&key, &encrypted).unwrap(); 52 + let decrypted: DocumentMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 53 53 54 54 assert_eq!(decrypted.name, "unnamed"); 55 55 assert!(decrypted.mime_type.is_none()); ··· 78 78 assert!(json.get("mimeType").is_some()); 79 79 assert!(json.get("mime_type").is_none()); 80 80 } 81 + 82 + #[test] 83 + fn keyring_metadata_roundtrip() { 84 + let key = generate_content_key(&mut OsRng); 85 + let metadata = KeyringMetadata { 86 + name: "family-photos".into(), 87 + description: Some("Photos from the holidays".into()), 88 + }; 89 + 90 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 91 + let decrypted: KeyringMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 92 + 93 + assert_eq!(decrypted.name, "family-photos"); 94 + assert_eq!( 95 + decrypted.description.as_deref(), 96 + Some("Photos from the holidays") 97 + ); 98 + } 99 + 100 + #[test] 101 + fn keyring_metadata_minimal() { 102 + let key = generate_content_key(&mut OsRng); 103 + let metadata = KeyringMetadata { 104 + name: "bare".into(), 105 + description: None, 106 + }; 107 + 108 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 109 + let decrypted: KeyringMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 110 + 111 + assert_eq!(decrypted.name, "bare"); 112 + assert!(decrypted.description.is_none()); 113 + } 114 + 115 + #[test] 116 + fn grant_metadata_roundtrip() { 117 + let key = generate_content_key(&mut OsRng); 118 + let metadata = GrantMetadata { 119 + permissions: Some("read".into()), 120 + note: Some("shared for review".into()), 121 + }; 122 + 123 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 124 + let decrypted: GrantMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 125 + 126 + assert_eq!(decrypted.permissions.as_deref(), Some("read")); 127 + assert_eq!(decrypted.note.as_deref(), Some("shared for review")); 128 + } 129 + 130 + #[test] 131 + fn grant_metadata_minimal() { 132 + let key = generate_content_key(&mut OsRng); 133 + let metadata = GrantMetadata { 134 + permissions: None, 135 + note: None, 136 + }; 137 + 138 + let encrypted = encrypt_metadata(&key, &metadata, &mut OsRng).unwrap(); 139 + let decrypted: GrantMetadata = decrypt_metadata(&key, &encrypted).unwrap(); 140 + 141 + assert!(decrypted.permissions.is_none()); 142 + assert!(decrypted.note.is_none()); 143 + }
+3 -1
crates/opake-core/src/crypto/mod.rs
··· 30 30 pub use content::{decrypt_blob, encrypt_blob, generate_content_key}; 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 - pub use metadata::{decrypt_metadata, encrypt_metadata, DocumentMetadata}; 33 + pub use metadata::{ 34 + decrypt_metadata, encrypt_metadata, DocumentMetadata, GrantMetadata, KeyringMetadata, 35 + }; 34 36 35 37 const WRAP_ALGO: &str = "x25519-hkdf-a256kw"; 36 38 const CONTENT_KEY_LEN: usize = 32;
+2 -1
crates/opake-core/src/documents/download.rs
··· 31 31 doc: &Document, 32 32 content_key: &ContentKey, 33 33 ) -> Result<String, Error> { 34 - let metadata = crypto::decrypt_metadata(content_key, &doc.encrypted_metadata)?; 34 + let metadata: crypto::DocumentMetadata = 35 + crypto::decrypt_metadata(content_key, &doc.encrypted_metadata)?; 35 36 Ok(metadata.name) 36 37 } 37 38
+2 -1
crates/opake-core/src/documents/download_grant.rs
··· 110 110 AtBytes, BlobRef, CidLink, DirectEncryption, EncryptedMetadata, Encryption, 111 111 EncryptionEnvelope, 112 112 }; 113 - use crate::test_utils::MockTransport; 113 + use crate::test_utils::{dummy_encrypted_metadata, MockTransport}; 114 114 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 115 115 116 116 const OWNER_DID: &str = "did:plc:owner"; ··· 263 263 DOC_URI.to_string(), 264 264 "did:plc:recipient".to_string(), 265 265 fixture.recipient_wrapped, 266 + dummy_encrypted_metadata(), 266 267 "2026-03-01T12:00:00Z".to_string(), 267 268 ); 268 269
+2 -2
crates/opake-core/src/documents/download_keyring.rs
··· 112 112 } 113 113 .ok_or_else(|| { 114 114 Error::InvalidRecord(format!( 115 - "DID ({member_did}) is not a member of keyring {:?} at rotation {doc_rotation}", 116 - keyring.name, 115 + "DID ({member_did}) is not a member of keyring {} at rotation {doc_rotation}", 116 + kr_enc.keyring_ref.keyring, 117 117 )) 118 118 })?; 119 119
+2 -2
crates/opake-core/src/documents/download_keyring_tests.rs
··· 4 4 use crate::records::{ 5 5 AtBytes, BlobRef, CidLink, EncryptedMetadata, KeyringEncryption, KeyringRef, WrappedKey, 6 6 }; 7 - use crate::test_utils::MockTransport; 7 + use crate::test_utils::{dummy_encrypted_metadata, MockTransport}; 8 8 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 9 9 10 10 const OWNER_DID: &str = "did:plc:owner"; ··· 142 142 143 143 fn keyring_record(fixture: &KeyringFixture) -> Keyring { 144 144 Keyring::new( 145 - "test-keyring".into(), 146 145 vec![ 147 146 fixture.owner_wrapped_gk.clone(), 148 147 fixture.member_wrapped_gk.clone(), 149 148 ], 149 + dummy_encrypted_metadata(), 150 150 "2026-03-01T00:00:00Z".into(), 151 151 ) 152 152 }
+2 -1
crates/opake-core/src/documents/upload.rs
··· 364 364 let content_key = crypto::unwrap_key(&envelope.keys[0], &private_key).unwrap(); 365 365 366 366 // Decrypt metadata 367 - let metadata = crypto::decrypt_metadata(&content_key, &doc.encrypted_metadata).unwrap(); 367 + let metadata: crypto::DocumentMetadata = 368 + crypto::decrypt_metadata(&content_key, &doc.encrypted_metadata).unwrap(); 368 369 369 370 assert_eq!(metadata.name, "report.pdf"); 370 371 assert_eq!(metadata.mime_type.as_deref(), Some("application/pdf"));
+2 -3
crates/opake-core/src/keyrings/add_member.rs
··· 56 56 use crate::client::{HttpResponse, LegacySession, RequestBody, Session, XrpcClient}; 57 57 use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 58 58 use crate::records::{AtBytes, Keyring, WrappedKey, SCHEMA_VERSION}; 59 - use crate::test_utils::MockTransport; 59 + use crate::test_utils::{dummy_encrypted_metadata, MockTransport}; 60 60 61 61 const TEST_DID: &str = "did:plc:owner"; 62 62 const KEYRING_URI: &str = "at://did:plc:owner/app.opake.keyring/kr1"; ··· 80 80 fn existing_keyring(owner_did: &str) -> Keyring { 81 81 Keyring { 82 82 opake_version: SCHEMA_VERSION, 83 - name: "test-keyring".into(), 84 - description: None, 85 83 algo: "aes-256-gcm".into(), 86 84 members: vec![WrappedKey { 87 85 did: owner_did.into(), ··· 92 90 }], 93 91 rotation: 0, 94 92 key_history: Vec::new(), 93 + encrypted_metadata: dummy_encrypted_metadata(), 95 94 created_at: "2026-03-01T00:00:00Z".into(), 96 95 modified_at: None, 97 96 }
+11 -3
crates/opake-core/src/keyrings/create.rs
··· 1 1 use log::debug; 2 2 3 3 use crate::client::{Transport, XrpcClient}; 4 - use crate::crypto::{self, ContentKey, CryptoRng, RngCore, X25519PublicKey}; 4 + use crate::crypto::{self, ContentKey, CryptoRng, KeyringMetadata, RngCore, X25519PublicKey}; 5 5 use crate::error::Error; 6 6 use crate::records::Keyring; 7 7 ··· 10 10 /// Everything needed to create a keyring record. 11 11 pub struct CreateKeyringParams<'a> { 12 12 pub name: &'a str, 13 + pub description: Option<&'a str>, 13 14 pub owner_did: &'a str, 14 15 pub owner_public_key: &'a X25519PublicKey, 15 16 pub created_at: &'a str, ··· 28 29 let members = [(params.owner_did, params.owner_public_key)]; 29 30 let (group_key, wrapped_keys) = crypto::create_group_key(&members, rng)?; 30 31 32 + let metadata = KeyringMetadata { 33 + name: params.name.to_string(), 34 + description: params.description.map(String::from), 35 + }; 36 + let encrypted_metadata = crypto::encrypt_metadata(&group_key, &metadata, rng)?; 37 + 31 38 let keyring = Keyring::new( 32 - params.name.to_string(), 33 39 wrapped_keys, 40 + encrypted_metadata, 34 41 params.created_at.to_string(), 35 42 ); 36 43 ··· 88 95 let mut client = mock_client(mock.clone()); 89 96 let params = CreateKeyringParams { 90 97 name: "family-photos", 98 + description: None, 91 99 owner_did: TEST_DID, 92 100 owner_public_key: &pubkey, 93 101 created_at: "2026-03-01T00:00:00Z", ··· 108 116 Some(RequestBody::Json(v)) => { 109 117 assert_eq!(v["collection"], KEYRING_COLLECTION); 110 118 let record: Keyring = serde_json::from_value(v["record"].clone()).unwrap(); 111 - assert_eq!(record.name, "family-photos"); 112 119 assert_eq!(record.algo, "aes-256-gcm"); 113 120 assert_eq!(record.rotation, 0); 114 121 assert_eq!(record.members.len(), 1); ··· 135 142 let mut client = mock_client(mock); 136 143 let params = CreateKeyringParams { 137 144 name: "broken", 145 + description: None, 138 146 owner_did: TEST_DID, 139 147 owner_public_key: &pubkey, 140 148 created_at: "2026-03-01T00:00:00Z",
+16 -23
crates/opake-core/src/keyrings/list.rs
··· 1 1 use crate::client::{list_collection, Transport, XrpcClient}; 2 2 use crate::error::Error; 3 - use crate::records::Keyring; 3 + use crate::records::{EncryptedMetadata, Keyring}; 4 4 5 5 use super::KEYRING_COLLECTION; 6 6 ··· 8 8 #[derive(Debug)] 9 9 pub struct KeyringEntry { 10 10 pub uri: String, 11 - pub name: String, 12 11 pub member_count: usize, 13 12 pub rotation: u64, 13 + pub encrypted_metadata: EncryptedMetadata, 14 + pub members: Vec<crate::records::WrappedKey>, 14 15 pub created_at: String, 15 16 } 16 17 ··· 21 22 list_collection(client, KEYRING_COLLECTION, |uri, keyring: Keyring| { 22 23 KeyringEntry { 23 24 uri: uri.to_owned(), 24 - name: keyring.name, 25 25 member_count: keyring.members.len(), 26 26 rotation: keyring.rotation, 27 + encrypted_metadata: keyring.encrypted_metadata, 28 + members: keyring.members, 27 29 created_at: keyring.created_at, 28 30 } 29 31 }) ··· 35 37 use super::*; 36 38 use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 37 39 use crate::records::{self, AtBytes, Keyring, WrappedKey}; 38 - use crate::test_utils::MockTransport; 40 + use crate::test_utils::{dummy_encrypted_metadata, MockTransport}; 39 41 40 42 const TEST_DID: &str = "did:plc:owner"; 41 43 ··· 49 51 XrpcClient::with_session(mock, "https://pds.test".into(), session) 50 52 } 51 53 52 - fn dummy_keyring(name: &str, member_count: usize) -> Keyring { 54 + fn dummy_keyring(member_count: usize) -> Keyring { 53 55 let members: Vec<WrappedKey> = (0..member_count) 54 56 .map(|i| WrappedKey { 55 57 did: format!("did:plc:member{i}"), ··· 62 64 63 65 Keyring { 64 66 opake_version: records::SCHEMA_VERSION, 65 - name: name.into(), 66 - description: None, 67 67 algo: "aes-256-gcm".into(), 68 68 members, 69 69 rotation: 0, 70 70 key_history: Vec::new(), 71 + encrypted_metadata: dummy_encrypted_metadata(), 71 72 created_at: "2026-03-01T00:00:00Z".into(), 72 73 modified_at: None, 73 74 } ··· 100 101 #[tokio::test] 101 102 async fn single_keyring() { 102 103 let mock = MockTransport::new(); 103 - mock.enqueue(list_response( 104 - &[("kr1", dummy_keyring("family-photos", 2))], 105 - None, 106 - )); 104 + mock.enqueue(list_response(&[("kr1", dummy_keyring(2))], None)); 107 105 108 106 let mut client = mock_client(mock.clone()); 109 107 let entries = list_keyrings(&mut client).await.unwrap(); 110 108 111 109 assert_eq!(entries.len(), 1); 112 - assert_eq!(entries[0].name, "family-photos"); 113 110 assert_eq!(entries[0].member_count, 2); 114 111 assert_eq!(entries[0].rotation, 0); 115 112 assert!(entries[0].uri.contains("kr1")); ··· 122 119 async fn multiple_keyrings() { 123 120 let mock = MockTransport::new(); 124 121 mock.enqueue(list_response( 125 - &[ 126 - ("kr1", dummy_keyring("photos", 1)), 127 - ("kr2", dummy_keyring("documents", 3)), 128 - ], 122 + &[("kr1", dummy_keyring(1)), ("kr2", dummy_keyring(3))], 129 123 None, 130 124 )); 131 125 ··· 133 127 let entries = list_keyrings(&mut client).await.unwrap(); 134 128 135 129 assert_eq!(entries.len(), 2); 136 - assert_eq!(entries[0].name, "photos"); 137 - assert_eq!(entries[1].name, "documents"); 130 + assert_eq!(entries[0].member_count, 1); 138 131 assert_eq!(entries[1].member_count, 3); 139 132 } 140 133 ··· 152 145 async fn paginates() { 153 146 let mock = MockTransport::new(); 154 147 mock.enqueue(list_response( 155 - &[("kr1", dummy_keyring("first", 1))], 148 + &[("kr1", dummy_keyring(1))], 156 149 Some("cursor-1"), 157 150 )); 158 - mock.enqueue(list_response(&[("kr2", dummy_keyring("second", 1))], None)); 151 + mock.enqueue(list_response(&[("kr2", dummy_keyring(1))], None)); 159 152 160 153 let mut client = mock_client(mock.clone()); 161 154 let entries = list_keyrings(&mut client).await.unwrap(); 162 155 163 156 assert_eq!(entries.len(), 2); 164 - assert_eq!(entries[0].name, "first"); 165 - assert_eq!(entries[1].name, "second"); 157 + assert!(entries[0].uri.contains("kr1")); 158 + assert!(entries[1].uri.contains("kr2")); 166 159 167 160 let reqs = mock.requests(); 168 161 assert!(reqs[1].url.contains("cursor=cursor-1")); ··· 170 163 171 164 #[tokio::test] 172 165 async fn skips_future_version() { 173 - let mut kr = dummy_keyring("future", 1); 166 + let mut kr = dummy_keyring(1); 174 167 kr.opake_version = records::SCHEMA_VERSION + 1; 175 168 176 169 let mock = MockTransport::new();
+33 -3
crates/opake-core/src/keyrings/mod.rs
··· 16 16 pub use remove_member::{remove_member, MemberKey}; 17 17 18 18 use crate::client::{Transport, XrpcClient}; 19 + use crate::crypto::{self, KeyringMetadata, X25519PrivateKey}; 19 20 use crate::error::Error; 20 21 21 22 pub const KEYRING_COLLECTION: &str = "app.opake.keyring"; 22 23 23 - /// Resolve a keyring name to its AT-URI by listing all keyrings and matching. 24 + /// Resolve a keyring name to its AT-URI by listing all keyrings, decrypting 25 + /// metadata, and matching by name. 24 26 /// 25 - /// Errors if zero or multiple keyrings share the name. 27 + /// Requires the caller's DID and private key to unwrap each keyring's group 28 + /// key for metadata decryption. Errors if zero or multiple keyrings share 29 + /// the name. 26 30 pub async fn resolve_keyring_uri( 27 31 client: &mut XrpcClient<impl Transport>, 28 32 name: &str, 33 + did: &str, 34 + private_key: &X25519PrivateKey, 29 35 ) -> Result<KeyringEntry, Error> { 30 36 let keyrings = list_keyrings(client).await?; 31 - let matches: Vec<_> = keyrings.into_iter().filter(|k| k.name == name).collect(); 37 + let mut matches = Vec::new(); 38 + 39 + for entry in keyrings { 40 + if let Some(decrypted_name) = decrypt_keyring_name(&entry, did, private_key) { 41 + if decrypted_name == name { 42 + matches.push(entry); 43 + } 44 + } 45 + } 32 46 33 47 match matches.len() { 34 48 0 => Err(Error::NotFound(format!("no keyring named {name:?}"))), ··· 43 57 } 44 58 } 45 59 } 60 + 61 + /// Decrypt a keyring entry's name from its encrypted metadata. 62 + /// 63 + /// Returns `None` if the group key can't be unwrapped (not a member) or 64 + /// metadata decryption fails. 65 + pub fn decrypt_keyring_name( 66 + entry: &KeyringEntry, 67 + did: &str, 68 + private_key: &X25519PrivateKey, 69 + ) -> Option<String> { 70 + let wrapped = entry.members.iter().find(|m| m.did == did)?; 71 + let group_key = crypto::unwrap_key(wrapped, private_key).ok()?; 72 + let metadata: KeyringMetadata = 73 + crypto::decrypt_metadata(&group_key, &entry.encrypted_metadata).ok()?; 74 + Some(metadata.name) 75 + }
+10 -1
crates/opake-core/src/keyrings/remove_member.rs
··· 2 2 3 3 use crate::atproto; 4 4 use crate::client::{Transport, XrpcClient}; 5 - use crate::crypto::{self, ContentKey, CryptoRng, RngCore, X25519PublicKey}; 5 + use crate::crypto::{self, ContentKey, CryptoRng, KeyringMetadata, RngCore, X25519PublicKey}; 6 6 use crate::error::Error; 7 7 use crate::records::{self, KeyHistoryEntry, Keyring}; 8 8 ··· 17 17 /// Remove a member from a keyring, rotate the group key, and re-wrap to 18 18 /// remaining members. 19 19 /// 20 + /// `old_group_key` is needed to decrypt the existing encrypted metadata so it 21 + /// can be re-encrypted under the new group key. 22 + /// 20 23 /// Returns `(new_group_key, new_rotation)` — the caller must store the key 21 24 /// against the rotation number locally. 22 25 /// ··· 33 36 keyring_uri: &str, 34 37 remove_did: &str, 35 38 remaining_keys: &[MemberKey<'_>], 39 + old_group_key: &ContentKey, 36 40 modified_at: &str, 37 41 rng: &mut (impl CryptoRng + RngCore), 38 42 ) -> Result<(ContentKey, u64), Error> { ··· 72 76 .map(|mk| (mk.did, mk.public_key)) 73 77 .collect(); 74 78 let (new_group_key, new_wrapped) = crypto::create_group_key(&did_keys, rng)?; 79 + 80 + // Re-encrypt metadata: decrypt with old group key, encrypt with new one 81 + let metadata: KeyringMetadata = 82 + crypto::decrypt_metadata(old_group_key, &keyring.encrypted_metadata)?; 83 + keyring.encrypted_metadata = crypto::encrypt_metadata(&new_group_key, &metadata, rng)?; 75 84 76 85 keyring.members = new_wrapped; 77 86 keyring.rotation += 1;
+36 -23
crates/opake-core/src/keyrings/remove_member_tests.rs
··· 1 1 use super::*; 2 2 use crate::client::{HttpResponse, LegacySession, RequestBody, Session, XrpcClient}; 3 - use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 3 + use crate::crypto::{self, OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 4 4 use crate::records::{AtBytes, Keyring, WrappedKey}; 5 - use crate::test_utils::MockTransport; 5 + use crate::test_utils::{dummy_encrypted_metadata, MockTransport}; 6 6 7 7 const TEST_DID: &str = "did:plc:owner"; 8 8 const KEYRING_URI: &str = "at://did:plc:owner/app.opake.keyring/kr1"; ··· 23 23 (public.to_bytes(), secret.to_bytes()) 24 24 } 25 25 26 - fn two_member_keyring() -> Keyring { 27 - Keyring::new( 28 - "test-keyring".into(), 29 - vec![ 30 - WrappedKey { 31 - did: TEST_DID.into(), 32 - ciphertext: AtBytes { 33 - encoded: "AAAA".into(), 34 - }, 35 - algo: "x25519-hkdf-a256kw".into(), 26 + fn two_member_keyring() -> (Keyring, ContentKey) { 27 + let members_keys = [ 28 + (TEST_DID, &test_keypair().0), 29 + ("did:plc:bob", &test_keypair().0), 30 + ]; 31 + // We need a real group key so remove_member can decrypt metadata 32 + let group_key = crypto::generate_content_key(&mut OsRng); 33 + let metadata = crypto::KeyringMetadata { 34 + name: "test-keyring".into(), 35 + description: None, 36 + }; 37 + let encrypted_metadata = crypto::encrypt_metadata(&group_key, &metadata, &mut OsRng).unwrap(); 38 + 39 + let members = vec![ 40 + WrappedKey { 41 + did: TEST_DID.into(), 42 + ciphertext: AtBytes { 43 + encoded: "AAAA".into(), 36 44 }, 37 - WrappedKey { 38 - did: "did:plc:bob".into(), 39 - ciphertext: AtBytes { 40 - encoded: "BBBB".into(), 41 - }, 42 - algo: "x25519-hkdf-a256kw".into(), 45 + algo: "x25519-hkdf-a256kw".into(), 46 + }, 47 + WrappedKey { 48 + did: "did:plc:bob".into(), 49 + ciphertext: AtBytes { 50 + encoded: "BBBB".into(), 43 51 }, 44 - ], 45 - "2026-03-01T00:00:00Z".into(), 46 - ) 52 + algo: "x25519-hkdf-a256kw".into(), 53 + }, 54 + ]; 55 + 56 + let keyring = Keyring::new(members, encrypted_metadata, "2026-03-01T00:00:00Z".into()); 57 + (keyring, group_key) 47 58 } 48 59 49 60 fn get_record_response(keyring: &Keyring) -> HttpResponse { ··· 73 84 74 85 #[tokio::test] 75 86 async fn happy_path_removes_and_rotates() { 76 - let keyring = two_member_keyring(); 87 + let (keyring, old_group_key) = two_member_keyring(); 77 88 let (owner_pubkey, owner_privkey) = test_keypair(); 78 89 79 90 let mock = MockTransport::new(); ··· 91 102 KEYRING_URI, 92 103 "did:plc:bob", 93 104 &remaining, 105 + &old_group_key, 94 106 "2026-03-01T12:00:00Z", 95 107 &mut OsRng, 96 108 ) ··· 128 140 129 141 #[tokio::test] 130 142 async fn rejects_nonexistent_member() { 131 - let keyring = two_member_keyring(); 143 + let (keyring, old_group_key) = two_member_keyring(); 132 144 let (owner_pubkey, _) = test_keypair(); 133 145 134 146 let mock = MockTransport::new(); ··· 145 157 KEYRING_URI, 146 158 "did:plc:nobody", 147 159 &remaining, 160 + &old_group_key, 148 161 "2026-03-01T12:00:00Z", 149 162 &mut OsRng, 150 163 )
+2 -1
crates/opake-core/src/metadata/read.rs
··· 38 38 39 39 let content_key = unwrap_content_key(&doc, did, private_key, group_key)?; 40 40 41 - let metadata = crypto::decrypt_metadata(&content_key, &doc.encrypted_metadata)?; 41 + let metadata: DocumentMetadata = 42 + crypto::decrypt_metadata(&content_key, &doc.encrypted_metadata)?; 42 43 43 44 Ok(DocumentMetadataResult { 44 45 metadata,
+4 -7
crates/opake-core/src/records/grant.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 3 - use super::{default_version, WrappedKey, SCHEMA_VERSION}; 3 + use super::{default_version, EncryptedMetadata, WrappedKey, SCHEMA_VERSION}; 4 4 5 5 #[derive(Debug, Clone, Serialize, Deserialize)] 6 6 #[serde(rename_all = "camelCase")] ··· 11 11 pub recipient: String, 12 12 pub wrapped_key: WrappedKey, 13 13 #[serde(skip_serializing_if = "Option::is_none")] 14 - pub permissions: Option<String>, 15 - #[serde(skip_serializing_if = "Option::is_none")] 16 14 pub expires_at: Option<String>, 17 - #[serde(skip_serializing_if = "Option::is_none")] 18 - pub note: Option<String>, 15 + pub encrypted_metadata: EncryptedMetadata, 19 16 pub created_at: String, 20 17 } 21 18 ··· 24 21 document: String, 25 22 recipient: String, 26 23 wrapped_key: WrappedKey, 24 + encrypted_metadata: EncryptedMetadata, 27 25 created_at: String, 28 26 ) -> Self { 29 27 Self { ··· 31 29 document, 32 30 recipient, 33 31 wrapped_key, 34 - permissions: None, 35 32 expires_at: None, 36 - note: None, 33 + encrypted_metadata, 37 34 created_at, 38 35 } 39 36 }
+8 -8
crates/opake-core/src/records/keyring.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 3 - use super::{default_version, WrappedKey, SCHEMA_VERSION}; 3 + use super::{default_version, EncryptedMetadata, WrappedKey, SCHEMA_VERSION}; 4 4 5 5 /// A snapshot of a keyring's members at a given rotation, preserved so that 6 6 /// remaining members can still decrypt documents uploaded under older group keys. ··· 15 15 pub struct Keyring { 16 16 #[serde(default = "default_version")] 17 17 pub opake_version: u32, 18 - pub name: String, 19 - #[serde(skip_serializing_if = "Option::is_none")] 20 - pub description: Option<String>, 21 18 pub algo: String, 22 19 pub members: Vec<WrappedKey>, 23 20 #[serde(default)] 24 21 pub rotation: u64, 25 22 #[serde(default, skip_serializing_if = "Vec::is_empty")] 26 23 pub key_history: Vec<KeyHistoryEntry>, 24 + pub encrypted_metadata: EncryptedMetadata, 27 25 pub created_at: String, 28 26 #[serde(skip_serializing_if = "Option::is_none")] 29 27 pub modified_at: Option<String>, ··· 31 29 32 30 impl Keyring { 33 31 /// New keyring with current schema version and defaults. 34 - /// Set description/modified_at via struct update syntax. 35 - pub fn new(name: String, members: Vec<WrappedKey>, created_at: String) -> Self { 32 + pub fn new( 33 + members: Vec<WrappedKey>, 34 + encrypted_metadata: EncryptedMetadata, 35 + created_at: String, 36 + ) -> Self { 36 37 Self { 37 38 opake_version: SCHEMA_VERSION, 38 - name, 39 - description: None, 40 39 algo: "aes-256-gcm".into(), 41 40 members, 42 41 rotation: 0, 43 42 key_history: Vec::new(), 43 + encrypted_metadata, 44 44 created_at, 45 45 modified_at: None, 46 46 }
+12 -2
crates/opake-core/src/records/mod.rs
··· 172 172 // Verify they deserialize to an empty vec. 173 173 let json = serde_json::json!({ 174 174 "opakeVersion": 1, 175 - "name": "old-keyring", 176 175 "algo": "aes-256-gcm", 177 176 "members": [{ 178 177 "did": "did:plc:test", ··· 180 179 "algo": "x25519-hkdf-a256kw", 181 180 }], 182 181 "rotation": 0, 182 + "encryptedMetadata": { 183 + "ciphertext": { "$bytes": "AAAA" }, 184 + "nonce": { "$bytes": "AAAAAAAAAAAAAAAA" }, 185 + }, 183 186 "createdAt": "2026-03-01T00:00:00Z", 184 187 }); 185 188 ··· 190 193 #[test] 191 194 fn keyring_key_history_omitted_when_empty() { 192 195 let keyring = Keyring::new( 193 - "fresh".into(), 194 196 vec![WrappedKey { 195 197 did: "did:plc:test".into(), 196 198 ciphertext: AtBytes { ··· 198 200 }, 199 201 algo: "x25519-hkdf-a256kw".into(), 200 202 }], 203 + EncryptedMetadata { 204 + ciphertext: AtBytes { 205 + encoded: "AAAA".into(), 206 + }, 207 + nonce: AtBytes { 208 + encoded: "BBBB".into(), 209 + }, 210 + }, 201 211 "2026-03-01T00:00:00Z".into(), 202 212 ); 203 213
+14 -10
crates/opake-core/src/sharing/create.rs
··· 1 1 use log::debug; 2 2 3 3 use crate::client::{Transport, XrpcClient}; 4 - use crate::crypto::{self, ContentKey, CryptoRng, RngCore, X25519PublicKey}; 4 + use crate::crypto::{self, ContentKey, CryptoRng, GrantMetadata, RngCore, X25519PublicKey}; 5 5 use crate::error::Error; 6 6 use crate::records::Grant; 7 7 ··· 32 32 rng, 33 33 )?; 34 34 35 - let grant = Grant { 35 + let metadata = GrantMetadata { 36 36 permissions: Some(params.permissions.to_string()), 37 37 note: params.note.map(|n| n.to_string()), 38 - ..Grant::new( 39 - params.document_uri.to_string(), 40 - params.recipient_did.to_string(), 41 - wrapped_key, 42 - params.created_at.to_string(), 43 - ) 44 38 }; 39 + let encrypted_metadata = crypto::encrypt_metadata(params.content_key, &metadata, rng)?; 40 + 41 + let grant = Grant::new( 42 + params.document_uri.to_string(), 43 + params.recipient_did.to_string(), 44 + wrapped_key, 45 + encrypted_metadata, 46 + params.created_at.to_string(), 47 + ); 45 48 46 49 debug!("creating grant record"); 47 50 let record_ref = client.create_record(GRANT_COLLECTION, &grant).await?; ··· 115 118 assert_eq!(v["collection"], GRANT_COLLECTION); 116 119 let record = &v["record"]; 117 120 assert_eq!(record["recipient"], "did:plc:recipient"); 118 - assert_eq!(record["permissions"], "read"); 119 - assert_eq!(record["note"], "here you go"); 120 121 assert_eq!( 121 122 record["document"], 122 123 "at://did:plc:owner/app.opake.document/doc1" 123 124 ); 125 + // encrypted metadata envelope is present 126 + assert!(record["encryptedMetadata"]["ciphertext"]["$bytes"].is_string()); 127 + assert!(record["encryptedMetadata"]["nonce"]["$bytes"].is_string()); 124 128 } 125 129 _ => panic!("expected JSON body"), 126 130 }
+18 -23
crates/opake-core/src/sharing/list.rs
··· 1 1 use crate::client::{list_collection, Transport, XrpcClient}; 2 2 use crate::error::Error; 3 - use crate::records::Grant; 3 + use crate::records::{EncryptedMetadata, Grant}; 4 4 5 5 use super::GRANT_COLLECTION; 6 6 ··· 10 10 pub uri: String, 11 11 pub document: String, 12 12 pub recipient: String, 13 - pub permissions: Option<String>, 14 - pub note: Option<String>, 13 + pub encrypted_metadata: EncryptedMetadata, 14 + pub expires_at: Option<String>, 15 15 pub created_at: String, 16 16 } 17 17 ··· 25 25 uri: uri.to_owned(), 26 26 document: grant.document, 27 27 recipient: grant.recipient, 28 - permissions: grant.permissions, 29 - note: grant.note, 28 + encrypted_metadata: grant.encrypted_metadata, 29 + expires_at: grant.expires_at, 30 30 created_at: grant.created_at, 31 31 }) 32 32 .await ··· 37 37 use super::*; 38 38 use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 39 39 use crate::records::{self, AtBytes, Grant, WrappedKey}; 40 - use crate::test_utils::MockTransport; 40 + use crate::test_utils::{dummy_encrypted_metadata, MockTransport}; 41 41 42 42 const TEST_DID: &str = "did:plc:owner"; 43 43 ··· 52 52 } 53 53 54 54 fn dummy_grant(recipient: &str, doc_rkey: &str) -> Grant { 55 - Grant { 56 - permissions: Some("read".into()), 57 - note: Some("here you go".into()), 58 - ..Grant::new( 59 - format!("at://{TEST_DID}/app.opake.document/{doc_rkey}"), 60 - recipient.into(), 61 - WrappedKey { 62 - did: recipient.into(), 63 - ciphertext: AtBytes { 64 - encoded: "AAAA".into(), 65 - }, 66 - algo: "x25519-hkdf-a256kw".into(), 55 + Grant::new( 56 + format!("at://{TEST_DID}/app.opake.document/{doc_rkey}"), 57 + recipient.into(), 58 + WrappedKey { 59 + did: recipient.into(), 60 + ciphertext: AtBytes { 61 + encoded: "AAAA".into(), 67 62 }, 68 - "2026-03-01T12:00:00Z".into(), 69 - ) 70 - } 63 + algo: "x25519-hkdf-a256kw".into(), 64 + }, 65 + dummy_encrypted_metadata(), 66 + "2026-03-01T12:00:00Z".into(), 67 + ) 71 68 } 72 69 73 70 fn list_grants_response(grants: &[(&str, Grant)], cursor: Option<&str>) -> HttpResponse { ··· 105 102 106 103 assert_eq!(entries.len(), 1); 107 104 assert_eq!(entries[0].recipient, "did:plc:bob"); 108 - assert_eq!(entries[0].permissions.as_deref(), Some("read")); 109 - assert_eq!(entries[0].note.as_deref(), Some("here you go")); 110 105 assert!(entries[0].document.contains("doc1")); 111 106 assert!(entries[0].uri.contains("g1")); 112 107
+13
crates/opake-core/src/test_utils.rs
··· 5 5 6 6 use crate::client::{HttpRequest, HttpResponse, Transport}; 7 7 use crate::error::Error; 8 + use crate::records::{AtBytes, EncryptedMetadata}; 9 + 10 + /// A no-op encrypted metadata value for tests that don't exercise decryption. 11 + pub fn dummy_encrypted_metadata() -> EncryptedMetadata { 12 + EncryptedMetadata { 13 + ciphertext: AtBytes { 14 + encoded: "AAAA".into(), 15 + }, 16 + nonce: AtBytes { 17 + encoded: "BBBB".into(), 18 + }, 19 + } 20 + } 8 21 9 22 /// A test double for Transport that serves canned responses in FIFO order 10 23 /// and captures every request for post-hoc assertion.
+100 -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, OsRng, X25519PrivateKey, X25519PublicKey, 4 + ContentKey, DocumentMetadata, EncryptedPayload, GrantMetadata, KeyringMetadata, OsRng, 5 + X25519PrivateKey, X25519PublicKey, 5 6 }; 6 7 use opake_core::records::WrappedKey; 7 8 use opake_core::storage::Identity; ··· 243 244 ciphertext: opake_core::records::AtBytes::from_raw(ciphertext), 244 245 nonce: opake_core::records::AtBytes::from_raw(nonce), 245 246 }; 246 - let metadata = opake_core::crypto::decrypt_metadata(&content_key, &encrypted) 247 + let metadata: opake_core::crypto::DocumentMetadata = 248 + opake_core::crypto::decrypt_metadata(&content_key, &encrypted) 249 + .map_err(|e| JsError::new(&e.to_string()))?; 250 + serde_wasm_bindgen::to_value(&metadata).map_err(|e| JsError::new(&e.to_string())) 251 + } 252 + 253 + // --------------------------------------------------------------------------- 254 + // Keyring metadata encryption exports 255 + // --------------------------------------------------------------------------- 256 + 257 + /// Encrypt keyring metadata (name, description) with a group key. 258 + /// 259 + /// `metadata` must be a JS object with fields: name (string), description? (string). 260 + /// Returns `{ ciphertext: Uint8Array, nonce: Uint8Array }`. 261 + #[wasm_bindgen(js_name = encryptKeyringMetadata)] 262 + pub fn encrypt_keyring_metadata_js(key: &[u8], metadata: JsValue) -> Result<JsValue, JsError> { 263 + let group_key = content_key_from_slice(key)?; 264 + let metadata: KeyringMetadata = 265 + serde_wasm_bindgen::from_value(metadata).map_err(|e| JsError::new(&e.to_string()))?; 266 + let encrypted = opake_core::crypto::encrypt_metadata(&group_key, &metadata, &mut OsRng) 267 + .map_err(|e| JsError::new(&e.to_string()))?; 268 + 269 + let ciphertext = encrypted 270 + .ciphertext 271 + .decode() 272 + .map_err(|e| JsError::new(&e.to_string()))?; 273 + let nonce = encrypted 274 + .nonce 275 + .decode() 276 + .map_err(|e| JsError::new(&e.to_string()))?; 277 + 278 + let dto = EncryptedPayloadDto { ciphertext, nonce }; 279 + serde_wasm_bindgen::to_value(&dto).map_err(|e| JsError::new(&e.to_string())) 280 + } 281 + 282 + /// Decrypt keyring metadata back to a JS object. 283 + /// 284 + /// Returns `{ name: string, description?: string }`. 285 + #[wasm_bindgen(js_name = decryptKeyringMetadata)] 286 + pub fn decrypt_keyring_metadata_js( 287 + key: &[u8], 288 + ciphertext: &[u8], 289 + nonce: &[u8], 290 + ) -> Result<JsValue, JsError> { 291 + let group_key = content_key_from_slice(key)?; 292 + let encrypted = opake_core::records::EncryptedMetadata { 293 + ciphertext: opake_core::records::AtBytes::from_raw(ciphertext), 294 + nonce: opake_core::records::AtBytes::from_raw(nonce), 295 + }; 296 + let metadata: KeyringMetadata = opake_core::crypto::decrypt_metadata(&group_key, &encrypted) 297 + .map_err(|e| JsError::new(&e.to_string()))?; 298 + serde_wasm_bindgen::to_value(&metadata).map_err(|e| JsError::new(&e.to_string())) 299 + } 300 + 301 + // --------------------------------------------------------------------------- 302 + // Grant metadata encryption exports 303 + // --------------------------------------------------------------------------- 304 + 305 + /// Encrypt grant metadata (permissions, note) with a content key. 306 + /// 307 + /// `metadata` must be a JS object with fields: permissions? (string), note? (string). 308 + /// Returns `{ ciphertext: Uint8Array, nonce: Uint8Array }`. 309 + #[wasm_bindgen(js_name = encryptGrantMetadata)] 310 + pub fn encrypt_grant_metadata_js(key: &[u8], metadata: JsValue) -> Result<JsValue, JsError> { 311 + let content_key = content_key_from_slice(key)?; 312 + let metadata: GrantMetadata = 313 + serde_wasm_bindgen::from_value(metadata).map_err(|e| JsError::new(&e.to_string()))?; 314 + let encrypted = opake_core::crypto::encrypt_metadata(&content_key, &metadata, &mut OsRng) 315 + .map_err(|e| JsError::new(&e.to_string()))?; 316 + 317 + let ciphertext = encrypted 318 + .ciphertext 319 + .decode() 320 + .map_err(|e| JsError::new(&e.to_string()))?; 321 + let nonce = encrypted 322 + .nonce 323 + .decode() 324 + .map_err(|e| JsError::new(&e.to_string()))?; 325 + 326 + let dto = EncryptedPayloadDto { ciphertext, nonce }; 327 + serde_wasm_bindgen::to_value(&dto).map_err(|e| JsError::new(&e.to_string())) 328 + } 329 + 330 + /// Decrypt grant metadata back to a JS object. 331 + /// 332 + /// Returns `{ permissions?: string, note?: string }`. 333 + #[wasm_bindgen(js_name = decryptGrantMetadata)] 334 + pub fn decrypt_grant_metadata_js( 335 + key: &[u8], 336 + ciphertext: &[u8], 337 + nonce: &[u8], 338 + ) -> Result<JsValue, JsError> { 339 + let content_key = content_key_from_slice(key)?; 340 + let encrypted = opake_core::records::EncryptedMetadata { 341 + ciphertext: opake_core::records::AtBytes::from_raw(ciphertext), 342 + nonce: opake_core::records::AtBytes::from_raw(nonce), 343 + }; 344 + let metadata: GrantMetadata = opake_core::crypto::decrypt_metadata(&content_key, &encrypted) 247 345 .map_err(|e| JsError::new(&e.to_string()))?; 248 346 serde_wasm_bindgen::to_value(&metadata).map_err(|e| JsError::new(&e.to_string())) 249 347 }
+1 -1
lexicons/app.opake.defs.json
··· 77 77 78 78 "encryptedMetadata": { 79 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.", 80 + "description": "AES-256-GCM encrypted metadata payload. The ciphertext contains a JSON object with the real record metadata. Encrypted with the symmetric key that protects the parent record (content key for documents/grants, group key for keyrings).", 81 81 "required": ["ciphertext", "nonce"], 82 82 "properties": { 83 83 "ciphertext": {
+5 -13
lexicons/app.opake.grant.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["opakeVersion", "document", "recipient", "wrappedKey", "createdAt"], 11 + "required": ["opakeVersion", "document", "recipient", "wrappedKey", "encryptedMetadata", "createdAt"], 12 12 "properties": { 13 13 "opakeVersion": { 14 14 "type": "integer", ··· 30 30 "ref": "app.opake.defs#wrappedKey", 31 31 "description": "The document's content encryption key, wrapped to the recipient's DID public key." 32 32 }, 33 - "permissions": { 34 - "type": "string", 35 - "description": "What the recipient is allowed to do. Note: this is advisory — actual enforcement depends on the AppView / client. Cryptographically, anyone with the key can read.", 36 - "knownValues": [ 37 - "read", 38 - "read-write" 39 - ] 40 - }, 41 33 "expiresAt": { 42 34 "type": "string", 43 35 "format": "datetime", 44 36 "description": "Optional expiration. After this time, clients should stop serving the content (advisory — the wrapped key remains valid unless the document is re-encrypted)." 45 37 }, 46 - "note": { 47 - "type": "string", 48 - "description": "Optional message to the recipient (plaintext).", 49 - "maxLength": 512 38 + "encryptedMetadata": { 39 + "type": "ref", 40 + "ref": "app.opake.defs#encryptedMetadata", 41 + "description": "Encrypted grant metadata (permissions, note). Encrypted with the document's content key so both grantor and recipient can read it." 50 42 }, 51 43 "createdAt": { 52 44 "type": "string",
+6 -11
lexicons/app.opake.keyring.json
··· 25 25 "key": "tid", 26 26 "record": { 27 27 "type": "object", 28 - "required": ["opakeVersion", "name", "algo", "members", "createdAt"], 28 + "required": ["opakeVersion", "algo", "members", "encryptedMetadata", "createdAt"], 29 29 "properties": { 30 30 "opakeVersion": { 31 31 "type": "integer", 32 32 "description": "Schema version for the app.opake.* namespace. Clients should reject records with a version they do not understand.", 33 33 "minimum": 1 34 - }, 35 - "name": { 36 - "type": "string", 37 - "description": "Human-readable name for this keyring (e.g. 'family-photos', 'work-projects').", 38 - "maxLength": 256 39 - }, 40 - "description": { 41 - "type": "string", 42 - "description": "Optional description of this keyring's purpose.", 43 - "maxLength": 1024 44 34 }, 45 35 "algo": { 46 36 "type": "string", ··· 68 58 "createdAt": { 69 59 "type": "string", 70 60 "format": "datetime" 61 + }, 62 + "encryptedMetadata": { 63 + "type": "ref", 64 + "ref": "app.opake.defs#encryptedMetadata", 65 + "description": "Encrypted keyring metadata (name, description). Encrypted with the group key so only members can read it." 71 66 }, 72 67 "modifiedAt": { 73 68 "type": "string",