An encrypted personal cloud built on the AT Protocol.

Split records.rs into records/ module directory

Break the monolithic records.rs (377 lines) into focused submodules:
records/mod.rs (version infrastructure, re-exports, tests),
defs.rs, document.rs, public_key.rs, grant.rs, keyring.rs.

All types re-exported from mod.rs — no downstream import changes.

+381 -377
-377
crates/opake-core/src/records.rs
··· 1 - // Typed representations of the app.opake.cloud.* lexicon records. 2 - // 3 - // These mirror the lexicon JSON schemas and handle atproto's serialization 4 - // conventions ($type discriminators, $bytes for binary data, $link for CIDs). 5 - // 6 - // AT Protocol primitives (AtUri, AtBytes, CidLink, BlobRef) live in the 7 - // `atproto` module. The ones used as record fields are re-exported here. 8 - 9 - use serde::{Deserialize, Serialize}; 10 - 11 - use crate::error::Error; 12 - 13 - // Re-export atproto types that appear in record struct fields so that 14 - // downstream code using `records::AtBytes` etc. keeps working. 15 - pub use crate::atproto::{AtBytes, BlobRef, CidLink}; 16 - 17 - /// The current app.opake.cloud.* schema version this client understands. 18 - /// Records with version <= this are compatible; higher versions must be rejected. 19 - pub const SCHEMA_VERSION: u32 = 1; 20 - 21 - /// Record types that carry a schema version number. 22 - pub trait Versioned { 23 - fn version(&self) -> u32; 24 - } 25 - 26 - macro_rules! impl_versioned { 27 - ($($ty:ty),+ $(,)?) => { 28 - $(impl Versioned for $ty { 29 - fn version(&self) -> u32 { self.version } 30 - })+ 31 - }; 32 - } 33 - 34 - fn default_version() -> u32 { 35 - SCHEMA_VERSION 36 - } 37 - 38 - /// Reject records written by a newer schema version than this client understands. 39 - pub fn check_version(record_version: u32) -> Result<(), Error> { 40 - if record_version > SCHEMA_VERSION { 41 - return Err(Error::InvalidRecord(format!( 42 - "record schema version {record_version} is newer than supported version {SCHEMA_VERSION}" 43 - ))); 44 - } 45 - Ok(()) 46 - } 47 - 48 - // --------------------------------------------------------------------------- 49 - // app.opake.cloud.defs 50 - // --------------------------------------------------------------------------- 51 - 52 - /// A symmetric key encrypted (wrapped) to a specific DID's public key. 53 - #[derive(Debug, Clone, Serialize, Deserialize)] 54 - pub struct WrappedKey { 55 - pub did: String, 56 - pub ciphertext: AtBytes, 57 - pub algo: String, 58 - } 59 - 60 - /// Describes how a blob's content was symmetrically encrypted, plus one or 61 - /// more wrapped copies of the content key for authorized DIDs. 62 - #[derive(Debug, Clone, Serialize, Deserialize)] 63 - pub struct EncryptionEnvelope { 64 - pub algo: String, 65 - pub nonce: AtBytes, 66 - pub keys: Vec<WrappedKey>, 67 - } 68 - 69 - /// Reference to a keyring whose group key protects the content key. 70 - #[derive(Debug, Clone, Serialize, Deserialize)] 71 - #[serde(rename_all = "camelCase")] 72 - pub struct KeyringRef { 73 - pub keyring: String, 74 - pub wrapped_content_key: AtBytes, 75 - pub rotation: u64, 76 - } 77 - 78 - // --------------------------------------------------------------------------- 79 - // app.opake.cloud.document — encryption union 80 - // --------------------------------------------------------------------------- 81 - 82 - /// Content key wrapped directly to individual DIDs. 83 - #[derive(Debug, Clone, Serialize, Deserialize)] 84 - pub struct DirectEncryption { 85 - pub envelope: EncryptionEnvelope, 86 - } 87 - 88 - /// Content key wrapped under a keyring's group key. 89 - #[derive(Debug, Clone, Serialize, Deserialize)] 90 - #[serde(rename_all = "camelCase")] 91 - pub struct KeyringEncryption { 92 - pub keyring_ref: KeyringRef, 93 - pub algo: String, 94 - pub nonce: AtBytes, 95 - } 96 - 97 - /// How to decrypt the blob — discriminated by `$type`. 98 - #[derive(Debug, Clone, Serialize, Deserialize)] 99 - #[serde(tag = "$type")] 100 - pub enum Encryption { 101 - #[serde(rename = "app.opake.cloud.document#directEncryption")] 102 - Direct(DirectEncryption), 103 - #[serde(rename = "app.opake.cloud.document#keyringEncryption")] 104 - Keyring(KeyringEncryption), 105 - } 106 - 107 - // --------------------------------------------------------------------------- 108 - // app.opake.cloud.document 109 - // --------------------------------------------------------------------------- 110 - 111 - #[derive(Debug, Clone, Serialize, Deserialize)] 112 - #[serde(rename_all = "camelCase")] 113 - pub struct Document { 114 - #[serde(default = "default_version")] 115 - pub version: u32, 116 - pub name: String, 117 - #[serde(skip_serializing_if = "Option::is_none")] 118 - pub mime_type: Option<String>, 119 - #[serde(skip_serializing_if = "Option::is_none")] 120 - pub size: Option<u64>, 121 - pub blob: BlobRef, 122 - pub encryption: Encryption, 123 - #[serde(default, skip_serializing_if = "Vec::is_empty")] 124 - pub tags: Vec<String>, 125 - #[serde(skip_serializing_if = "Option::is_none")] 126 - pub parent: Option<String>, 127 - #[serde(skip_serializing_if = "Option::is_none")] 128 - pub description: Option<String>, 129 - #[serde(skip_serializing_if = "Option::is_none")] 130 - pub visibility: Option<String>, 131 - pub created_at: String, 132 - #[serde(skip_serializing_if = "Option::is_none")] 133 - pub modified_at: Option<String>, 134 - } 135 - 136 - impl Document { 137 - /// Construct a new document with the current schema version and sensible 138 - /// defaults for optional fields. Callers set tags/parent/description/etc. 139 - /// via struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 140 - pub fn new(name: String, blob: BlobRef, encryption: Encryption, created_at: String) -> Self { 141 - Self { 142 - version: SCHEMA_VERSION, 143 - name, 144 - mime_type: None, 145 - size: None, 146 - blob, 147 - encryption, 148 - tags: Vec::new(), 149 - parent: None, 150 - description: None, 151 - visibility: None, 152 - created_at, 153 - modified_at: None, 154 - } 155 - } 156 - } 157 - 158 - // --------------------------------------------------------------------------- 159 - // app.opake.cloud.publicKey 160 - // --------------------------------------------------------------------------- 161 - 162 - pub const PUBLIC_KEY_COLLECTION: &str = "app.opake.cloud.publicKey"; 163 - pub const PUBLIC_KEY_RKEY: &str = "self"; 164 - 165 - /// Singleton public key record published on the user's PDS. 166 - /// Uses rkey "self" (like app.bsky.actor.profile). 167 - #[derive(Debug, Clone, Serialize, Deserialize)] 168 - #[serde(rename_all = "camelCase")] 169 - pub struct PublicKeyRecord { 170 - #[serde(default = "default_version")] 171 - pub version: u32, 172 - pub public_key: AtBytes, 173 - pub algo: String, 174 - pub created_at: String, 175 - } 176 - 177 - impl PublicKeyRecord { 178 - pub fn new(public_key_bytes: &[u8], created_at: &str) -> Self { 179 - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 180 - Self { 181 - version: SCHEMA_VERSION, 182 - public_key: AtBytes { 183 - encoded: BASE64.encode(public_key_bytes), 184 - }, 185 - algo: "x25519".into(), 186 - created_at: created_at.into(), 187 - } 188 - } 189 - } 190 - 191 - // --------------------------------------------------------------------------- 192 - // app.opake.cloud.grant 193 - // --------------------------------------------------------------------------- 194 - 195 - #[derive(Debug, Clone, Serialize, Deserialize)] 196 - #[serde(rename_all = "camelCase")] 197 - pub struct Grant { 198 - #[serde(default = "default_version")] 199 - pub version: u32, 200 - pub document: String, 201 - pub recipient: String, 202 - pub wrapped_key: WrappedKey, 203 - #[serde(skip_serializing_if = "Option::is_none")] 204 - pub permissions: Option<String>, 205 - #[serde(skip_serializing_if = "Option::is_none")] 206 - pub expires_at: Option<String>, 207 - #[serde(skip_serializing_if = "Option::is_none")] 208 - pub note: Option<String>, 209 - pub created_at: String, 210 - } 211 - 212 - impl Grant { 213 - pub fn new( 214 - document: String, 215 - recipient: String, 216 - wrapped_key: WrappedKey, 217 - created_at: String, 218 - ) -> Self { 219 - Self { 220 - version: SCHEMA_VERSION, 221 - document, 222 - recipient, 223 - wrapped_key, 224 - permissions: None, 225 - expires_at: None, 226 - note: None, 227 - created_at, 228 - } 229 - } 230 - } 231 - 232 - // --------------------------------------------------------------------------- 233 - // app.opake.cloud.keyring 234 - // --------------------------------------------------------------------------- 235 - 236 - /// A snapshot of a keyring's members at a given rotation, preserved so that 237 - /// remaining members can still decrypt documents uploaded under older group keys. 238 - #[derive(Debug, Clone, Serialize, Deserialize)] 239 - pub struct KeyHistoryEntry { 240 - pub rotation: u64, 241 - pub members: Vec<WrappedKey>, 242 - } 243 - 244 - #[derive(Debug, Clone, Serialize, Deserialize)] 245 - #[serde(rename_all = "camelCase")] 246 - pub struct Keyring { 247 - #[serde(default = "default_version")] 248 - pub version: u32, 249 - pub name: String, 250 - #[serde(skip_serializing_if = "Option::is_none")] 251 - pub description: Option<String>, 252 - pub algo: String, 253 - pub members: Vec<WrappedKey>, 254 - #[serde(default)] 255 - pub rotation: u64, 256 - #[serde(default, skip_serializing_if = "Vec::is_empty")] 257 - pub key_history: Vec<KeyHistoryEntry>, 258 - pub created_at: String, 259 - #[serde(skip_serializing_if = "Option::is_none")] 260 - pub modified_at: Option<String>, 261 - } 262 - 263 - impl Keyring { 264 - /// New keyring with current schema version and defaults. 265 - /// Set description/modified_at via struct update syntax. 266 - pub fn new(name: String, members: Vec<WrappedKey>, created_at: String) -> Self { 267 - Self { 268 - version: SCHEMA_VERSION, 269 - name, 270 - description: None, 271 - algo: "aes-256-gcm".into(), 272 - members, 273 - rotation: 0, 274 - key_history: Vec::new(), 275 - created_at, 276 - modified_at: None, 277 - } 278 - } 279 - } 280 - 281 - impl_versioned!(Document, PublicKeyRecord, Grant, Keyring); 282 - 283 - #[cfg(test)] 284 - mod tests { 285 - use super::*; 286 - 287 - #[test] 288 - fn check_version_accepts_current() { 289 - assert!(check_version(SCHEMA_VERSION).is_ok()); 290 - } 291 - 292 - #[test] 293 - fn check_version_accepts_v1() { 294 - assert!(check_version(1).is_ok()); 295 - } 296 - 297 - #[test] 298 - fn check_version_rejects_one_above() { 299 - let err = check_version(SCHEMA_VERSION + 1).unwrap_err(); 300 - assert!(matches!(err, Error::InvalidRecord(_))); 301 - } 302 - 303 - #[test] 304 - fn check_version_rejects_max() { 305 - assert!(check_version(u32::MAX).is_err()); 306 - } 307 - 308 - #[test] 309 - fn public_key_record_new_sets_defaults() { 310 - let record = PublicKeyRecord::new(&[42u8; 32], "2026-03-01T00:00:00Z"); 311 - assert_eq!(record.version, SCHEMA_VERSION); 312 - assert_eq!(record.algo, "x25519"); 313 - assert_eq!(record.created_at, "2026-03-01T00:00:00Z"); 314 - } 315 - 316 - #[test] 317 - fn public_key_record_roundtrips_through_json() { 318 - let record = PublicKeyRecord::new(&[7u8; 32], "2026-03-01T12:00:00Z"); 319 - let json = serde_json::to_string(&record).unwrap(); 320 - let parsed: PublicKeyRecord = serde_json::from_str(&json).unwrap(); 321 - 322 - assert_eq!(parsed.version, record.version); 323 - assert_eq!(parsed.public_key.encoded, record.public_key.encoded); 324 - assert_eq!(parsed.algo, "x25519"); 325 - assert_eq!(parsed.created_at, "2026-03-01T12:00:00Z"); 326 - } 327 - 328 - #[test] 329 - fn public_key_record_uses_atbytes_wire_format() { 330 - let record = PublicKeyRecord::new(&[1u8; 32], "2026-03-01T00:00:00Z"); 331 - let json = serde_json::to_value(&record).unwrap(); 332 - // atproto $bytes convention: { "$bytes": "<base64>" } 333 - assert!(json["publicKey"]["$bytes"].is_string()); 334 - } 335 - 336 - #[test] 337 - fn keyring_without_key_history_deserializes() { 338 - // Records created before key_history existed won't have the field. 339 - // Verify they deserialize to an empty vec. 340 - let json = serde_json::json!({ 341 - "version": 1, 342 - "name": "old-keyring", 343 - "algo": "aes-256-gcm", 344 - "members": [{ 345 - "did": "did:plc:test", 346 - "ciphertext": { "$bytes": "AAAA" }, 347 - "algo": "x25519-hkdf-a256kw", 348 - }], 349 - "rotation": 0, 350 - "createdAt": "2026-03-01T00:00:00Z", 351 - }); 352 - 353 - let keyring: Keyring = serde_json::from_value(json).unwrap(); 354 - assert!(keyring.key_history.is_empty()); 355 - } 356 - 357 - #[test] 358 - fn keyring_key_history_omitted_when_empty() { 359 - let keyring = Keyring::new( 360 - "fresh".into(), 361 - vec![WrappedKey { 362 - did: "did:plc:test".into(), 363 - ciphertext: AtBytes { 364 - encoded: "AAAA".into(), 365 - }, 366 - algo: "x25519-hkdf-a256kw".into(), 367 - }], 368 - "2026-03-01T00:00:00Z".into(), 369 - ); 370 - 371 - let json = serde_json::to_value(&keyring).unwrap(); 372 - assert!( 373 - json.get("keyHistory").is_none(), 374 - "empty key_history should be omitted from serialization" 375 - ); 376 - } 377 - }
+29
crates/opake-core/src/records/defs.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use crate::atproto::AtBytes; 4 + 5 + /// A symmetric key encrypted (wrapped) to a specific DID's public key. 6 + #[derive(Debug, Clone, Serialize, Deserialize)] 7 + pub struct WrappedKey { 8 + pub did: String, 9 + pub ciphertext: AtBytes, 10 + pub algo: String, 11 + } 12 + 13 + /// Describes how a blob's content was symmetrically encrypted, plus one or 14 + /// more wrapped copies of the content key for authorized DIDs. 15 + #[derive(Debug, Clone, Serialize, Deserialize)] 16 + pub struct EncryptionEnvelope { 17 + pub algo: String, 18 + pub nonce: AtBytes, 19 + pub keys: Vec<WrappedKey>, 20 + } 21 + 22 + /// Reference to a keyring whose group key protects the content key. 23 + #[derive(Debug, Clone, Serialize, Deserialize)] 24 + #[serde(rename_all = "camelCase")] 25 + pub struct KeyringRef { 26 + pub keyring: String, 27 + pub wrapped_content_key: AtBytes, 28 + pub rotation: u64, 29 + }
+76
crates/opake-core/src/records/document.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use super::{default_version, EncryptionEnvelope, KeyringRef, SCHEMA_VERSION}; 4 + use crate::atproto::{AtBytes, BlobRef}; 5 + 6 + /// Content key wrapped directly to individual DIDs. 7 + #[derive(Debug, Clone, Serialize, Deserialize)] 8 + pub struct DirectEncryption { 9 + pub envelope: EncryptionEnvelope, 10 + } 11 + 12 + /// Content key wrapped under a keyring's group key. 13 + #[derive(Debug, Clone, Serialize, Deserialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct KeyringEncryption { 16 + pub keyring_ref: KeyringRef, 17 + pub algo: String, 18 + pub nonce: AtBytes, 19 + } 20 + 21 + /// How to decrypt the blob — discriminated by `$type`. 22 + #[derive(Debug, Clone, Serialize, Deserialize)] 23 + #[serde(tag = "$type")] 24 + pub enum Encryption { 25 + #[serde(rename = "app.opake.cloud.document#directEncryption")] 26 + Direct(DirectEncryption), 27 + #[serde(rename = "app.opake.cloud.document#keyringEncryption")] 28 + Keyring(KeyringEncryption), 29 + } 30 + 31 + #[derive(Debug, Clone, Serialize, Deserialize)] 32 + #[serde(rename_all = "camelCase")] 33 + pub struct Document { 34 + #[serde(default = "default_version")] 35 + pub version: u32, 36 + pub name: String, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub mime_type: Option<String>, 39 + #[serde(skip_serializing_if = "Option::is_none")] 40 + pub size: Option<u64>, 41 + pub blob: BlobRef, 42 + pub encryption: Encryption, 43 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 44 + pub tags: Vec<String>, 45 + #[serde(skip_serializing_if = "Option::is_none")] 46 + pub parent: Option<String>, 47 + #[serde(skip_serializing_if = "Option::is_none")] 48 + pub description: Option<String>, 49 + #[serde(skip_serializing_if = "Option::is_none")] 50 + pub visibility: Option<String>, 51 + pub created_at: String, 52 + #[serde(skip_serializing_if = "Option::is_none")] 53 + pub modified_at: Option<String>, 54 + } 55 + 56 + impl Document { 57 + /// Construct a new document with the current schema version and sensible 58 + /// defaults for optional fields. Callers set tags/parent/description/etc. 59 + /// via struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 60 + pub fn new(name: String, blob: BlobRef, encryption: Encryption, created_at: String) -> Self { 61 + Self { 62 + version: SCHEMA_VERSION, 63 + name, 64 + mime_type: None, 65 + size: None, 66 + blob, 67 + encryption, 68 + tags: Vec::new(), 69 + parent: None, 70 + description: None, 71 + visibility: None, 72 + created_at, 73 + modified_at: None, 74 + } 75 + } 76 + }
+40
crates/opake-core/src/records/grant.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use super::{default_version, WrappedKey, SCHEMA_VERSION}; 4 + 5 + #[derive(Debug, Clone, Serialize, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Grant { 8 + #[serde(default = "default_version")] 9 + pub version: u32, 10 + pub document: String, 11 + pub recipient: String, 12 + pub wrapped_key: WrappedKey, 13 + #[serde(skip_serializing_if = "Option::is_none")] 14 + pub permissions: Option<String>, 15 + #[serde(skip_serializing_if = "Option::is_none")] 16 + pub expires_at: Option<String>, 17 + #[serde(skip_serializing_if = "Option::is_none")] 18 + pub note: Option<String>, 19 + pub created_at: String, 20 + } 21 + 22 + impl Grant { 23 + pub fn new( 24 + document: String, 25 + recipient: String, 26 + wrapped_key: WrappedKey, 27 + created_at: String, 28 + ) -> Self { 29 + Self { 30 + version: SCHEMA_VERSION, 31 + document, 32 + recipient, 33 + wrapped_key, 34 + permissions: None, 35 + expires_at: None, 36 + note: None, 37 + created_at, 38 + } 39 + } 40 + }
+48
crates/opake-core/src/records/keyring.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use super::{default_version, WrappedKey, SCHEMA_VERSION}; 4 + 5 + /// A snapshot of a keyring's members at a given rotation, preserved so that 6 + /// remaining members can still decrypt documents uploaded under older group keys. 7 + #[derive(Debug, Clone, Serialize, Deserialize)] 8 + pub struct KeyHistoryEntry { 9 + pub rotation: u64, 10 + pub members: Vec<WrappedKey>, 11 + } 12 + 13 + #[derive(Debug, Clone, Serialize, Deserialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct Keyring { 16 + #[serde(default = "default_version")] 17 + pub version: u32, 18 + pub name: String, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub description: Option<String>, 21 + pub algo: String, 22 + pub members: Vec<WrappedKey>, 23 + #[serde(default)] 24 + pub rotation: u64, 25 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 26 + pub key_history: Vec<KeyHistoryEntry>, 27 + pub created_at: String, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub modified_at: Option<String>, 30 + } 31 + 32 + impl Keyring { 33 + /// 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 { 36 + Self { 37 + version: SCHEMA_VERSION, 38 + name, 39 + description: None, 40 + algo: "aes-256-gcm".into(), 41 + members, 42 + rotation: 0, 43 + key_history: Vec::new(), 44 + created_at, 45 + modified_at: None, 46 + } 47 + } 48 + }
+155
crates/opake-core/src/records/mod.rs
··· 1 + // Typed representations of the app.opake.cloud.* lexicon records. 2 + // 3 + // These mirror the lexicon JSON schemas and handle atproto's serialization 4 + // conventions ($type discriminators, $bytes for binary data, $link for CIDs). 5 + // 6 + // AT Protocol primitives (AtUri, AtBytes, CidLink, BlobRef) live in the 7 + // `atproto` module. The ones used as record fields are re-exported here. 8 + 9 + mod defs; 10 + mod document; 11 + mod grant; 12 + mod keyring; 13 + mod public_key; 14 + 15 + use crate::error::Error; 16 + 17 + // Re-export atproto types that appear in record struct fields so that 18 + // downstream code using `records::AtBytes` etc. keeps working. 19 + pub use crate::atproto::{AtBytes, BlobRef, CidLink}; 20 + 21 + // Re-export all record types at the `records::` level. 22 + pub use defs::{EncryptionEnvelope, KeyringRef, WrappedKey}; 23 + pub use document::{DirectEncryption, Document, Encryption, KeyringEncryption}; 24 + pub use grant::Grant; 25 + pub use keyring::{KeyHistoryEntry, Keyring}; 26 + pub use public_key::{PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; 27 + 28 + /// The current app.opake.cloud.* schema version this client understands. 29 + /// Records with version <= this are compatible; higher versions must be rejected. 30 + pub const SCHEMA_VERSION: u32 = 1; 31 + 32 + /// Record types that carry a schema version number. 33 + pub trait Versioned { 34 + fn version(&self) -> u32; 35 + } 36 + 37 + macro_rules! impl_versioned { 38 + ($($ty:ty),+ $(,)?) => { 39 + $(impl Versioned for $ty { 40 + fn version(&self) -> u32 { self.version } 41 + })+ 42 + }; 43 + } 44 + 45 + impl_versioned!(Document, PublicKeyRecord, Grant, Keyring); 46 + 47 + fn default_version() -> u32 { 48 + SCHEMA_VERSION 49 + } 50 + 51 + /// Reject records written by a newer schema version than this client understands. 52 + pub fn check_version(record_version: u32) -> Result<(), Error> { 53 + if record_version > SCHEMA_VERSION { 54 + return Err(Error::InvalidRecord(format!( 55 + "record schema version {record_version} is newer than supported version {SCHEMA_VERSION}" 56 + ))); 57 + } 58 + Ok(()) 59 + } 60 + 61 + #[cfg(test)] 62 + mod tests { 63 + use super::*; 64 + 65 + #[test] 66 + fn check_version_accepts_current() { 67 + assert!(check_version(SCHEMA_VERSION).is_ok()); 68 + } 69 + 70 + #[test] 71 + fn check_version_accepts_v1() { 72 + assert!(check_version(1).is_ok()); 73 + } 74 + 75 + #[test] 76 + fn check_version_rejects_one_above() { 77 + let err = check_version(SCHEMA_VERSION + 1).unwrap_err(); 78 + assert!(matches!(err, Error::InvalidRecord(_))); 79 + } 80 + 81 + #[test] 82 + fn check_version_rejects_max() { 83 + assert!(check_version(u32::MAX).is_err()); 84 + } 85 + 86 + #[test] 87 + fn public_key_record_new_sets_defaults() { 88 + let record = PublicKeyRecord::new(&[42u8; 32], "2026-03-01T00:00:00Z"); 89 + assert_eq!(record.version, SCHEMA_VERSION); 90 + assert_eq!(record.algo, "x25519"); 91 + assert_eq!(record.created_at, "2026-03-01T00:00:00Z"); 92 + } 93 + 94 + #[test] 95 + fn public_key_record_roundtrips_through_json() { 96 + let record = PublicKeyRecord::new(&[7u8; 32], "2026-03-01T12:00:00Z"); 97 + let json = serde_json::to_string(&record).unwrap(); 98 + let parsed: PublicKeyRecord = serde_json::from_str(&json).unwrap(); 99 + 100 + assert_eq!(parsed.version, record.version); 101 + assert_eq!(parsed.public_key.encoded, record.public_key.encoded); 102 + assert_eq!(parsed.algo, "x25519"); 103 + assert_eq!(parsed.created_at, "2026-03-01T12:00:00Z"); 104 + } 105 + 106 + #[test] 107 + fn public_key_record_uses_atbytes_wire_format() { 108 + let record = PublicKeyRecord::new(&[1u8; 32], "2026-03-01T00:00:00Z"); 109 + let json = serde_json::to_value(&record).unwrap(); 110 + // atproto $bytes convention: { "$bytes": "<base64>" } 111 + assert!(json["publicKey"]["$bytes"].is_string()); 112 + } 113 + 114 + #[test] 115 + fn keyring_without_key_history_deserializes() { 116 + // Records created before key_history existed won't have the field. 117 + // Verify they deserialize to an empty vec. 118 + let json = serde_json::json!({ 119 + "version": 1, 120 + "name": "old-keyring", 121 + "algo": "aes-256-gcm", 122 + "members": [{ 123 + "did": "did:plc:test", 124 + "ciphertext": { "$bytes": "AAAA" }, 125 + "algo": "x25519-hkdf-a256kw", 126 + }], 127 + "rotation": 0, 128 + "createdAt": "2026-03-01T00:00:00Z", 129 + }); 130 + 131 + let keyring: Keyring = serde_json::from_value(json).unwrap(); 132 + assert!(keyring.key_history.is_empty()); 133 + } 134 + 135 + #[test] 136 + fn keyring_key_history_omitted_when_empty() { 137 + let keyring = Keyring::new( 138 + "fresh".into(), 139 + vec![WrappedKey { 140 + did: "did:plc:test".into(), 141 + ciphertext: AtBytes { 142 + encoded: "AAAA".into(), 143 + }, 144 + algo: "x25519-hkdf-a256kw".into(), 145 + }], 146 + "2026-03-01T00:00:00Z".into(), 147 + ); 148 + 149 + let json = serde_json::to_value(&keyring).unwrap(); 150 + assert!( 151 + json.get("keyHistory").is_none(), 152 + "empty key_history should be omitted from serialization" 153 + ); 154 + } 155 + }
+33
crates/opake-core/src/records/public_key.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use super::{default_version, SCHEMA_VERSION}; 4 + use crate::atproto::AtBytes; 5 + 6 + pub const PUBLIC_KEY_COLLECTION: &str = "app.opake.cloud.publicKey"; 7 + pub const PUBLIC_KEY_RKEY: &str = "self"; 8 + 9 + /// Singleton public key record published on the user's PDS. 10 + /// Uses rkey "self" (like app.bsky.actor.profile). 11 + #[derive(Debug, Clone, Serialize, Deserialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct PublicKeyRecord { 14 + #[serde(default = "default_version")] 15 + pub version: u32, 16 + pub public_key: AtBytes, 17 + pub algo: String, 18 + pub created_at: String, 19 + } 20 + 21 + impl PublicKeyRecord { 22 + pub fn new(public_key_bytes: &[u8], created_at: &str) -> Self { 23 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 24 + Self { 25 + version: SCHEMA_VERSION, 26 + public_key: AtBytes { 27 + encoded: BASE64.encode(public_key_bytes), 28 + }, 29 + algo: "x25519".into(), 30 + created_at: created_at.into(), 31 + } 32 + } 33 + }