this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add asymmetric key wrapping and schema versioning

Implement x25519-hkdf-a256kw key wrapping: ECDH key agreement via
X25519, HKDF-SHA256 key derivation, and AES-256 key wrap (RFC 3394).
Ciphertext packs ephemeral public key and wrapped content key for
atproto storage.

Add schema version field to all record types (document, grant, keyring)
with compatibility check for forward-safe decentralised record handling.
HKDF domain separation string includes schema version so version bumps
produce distinct derived keys.

Update wrappedKey algorithm from ECDH-ES+A256KW to x25519-hkdf-a256kw
to honestly reflect the KDF choice (HKDF, not JWE Concat KDF).

+421 -40
+1
CHANGELOG.md
··· 12 12 ### Fixed 13 13 14 14 ### Changed 15 + - Add asymmetric key wrapping (ECDH-ES+A256KW) (#4) 15 16 - Add AES-256-GCM content encryption and decryption (#3) 16 17 - Fix WASM compilation for opake-core by enabling getrandom js feature (#27) 17 18 - Add PDS authentication via com.atproto.server.createSession (#2)
+136
Cargo.lock
··· 38 38 ] 39 39 40 40 [[package]] 41 + name = "aes-kw" 42 + version = "0.2.1" 43 + source = "registry+https://github.com/rust-lang/crates.io-index" 44 + checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" 45 + dependencies = [ 46 + "aes", 47 + ] 48 + 49 + [[package]] 41 50 name = "aho-corasick" 42 51 version = "1.1.4" 43 52 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 119 128 version = "2.11.0" 120 129 source = "registry+https://github.com/rust-lang/crates.io-index" 121 130 checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 131 + 132 + [[package]] 133 + name = "block-buffer" 134 + version = "0.10.4" 135 + source = "registry+https://github.com/rust-lang/crates.io-index" 136 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 137 + dependencies = [ 138 + "generic-array", 139 + ] 122 140 123 141 [[package]] 124 142 name = "bumpalo" ··· 240 258 ] 241 259 242 260 [[package]] 261 + name = "curve25519-dalek" 262 + version = "4.1.3" 263 + source = "registry+https://github.com/rust-lang/crates.io-index" 264 + checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" 265 + dependencies = [ 266 + "cfg-if", 267 + "cpufeatures", 268 + "curve25519-dalek-derive", 269 + "fiat-crypto", 270 + "rustc_version", 271 + "subtle", 272 + "zeroize", 273 + ] 274 + 275 + [[package]] 276 + name = "curve25519-dalek-derive" 277 + version = "0.1.1" 278 + source = "registry+https://github.com/rust-lang/crates.io-index" 279 + checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" 280 + dependencies = [ 281 + "proc-macro2", 282 + "quote", 283 + "syn", 284 + ] 285 + 286 + [[package]] 287 + name = "digest" 288 + version = "0.10.7" 289 + source = "registry+https://github.com/rust-lang/crates.io-index" 290 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 291 + dependencies = [ 292 + "block-buffer", 293 + "crypto-common", 294 + "subtle", 295 + ] 296 + 297 + [[package]] 243 298 name = "displaydoc" 244 299 version = "0.2.5" 245 300 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 282 337 "libc", 283 338 "windows-sys 0.61.2", 284 339 ] 340 + 341 + [[package]] 342 + name = "fiat-crypto" 343 + version = "0.2.9" 344 + source = "registry+https://github.com/rust-lang/crates.io-index" 345 + checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" 285 346 286 347 [[package]] 287 348 name = "find-msvc-tools" ··· 385 446 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 386 447 387 448 [[package]] 449 + name = "hkdf" 450 + version = "0.12.4" 451 + source = "registry+https://github.com/rust-lang/crates.io-index" 452 + checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 453 + dependencies = [ 454 + "hmac", 455 + ] 456 + 457 + [[package]] 458 + name = "hmac" 459 + version = "0.12.1" 460 + source = "registry+https://github.com/rust-lang/crates.io-index" 461 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 462 + dependencies = [ 463 + "digest", 464 + ] 465 + 466 + [[package]] 388 467 name = "http" 389 468 version = "1.4.0" 390 469 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 739 818 version = "0.1.0" 740 819 dependencies = [ 741 820 "aes-gcm", 821 + "aes-kw", 822 + "base64", 742 823 "getrandom 0.2.17", 824 + "hkdf", 743 825 "log", 744 826 "serde", 745 827 "serde_json", 828 + "sha2", 746 829 "thiserror", 830 + "x25519-dalek", 747 831 ] 748 832 749 833 [[package]] ··· 1052 1136 checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 1053 1137 1054 1138 [[package]] 1139 + name = "rustc_version" 1140 + version = "0.4.1" 1141 + source = "registry+https://github.com/rust-lang/crates.io-index" 1142 + checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 1143 + dependencies = [ 1144 + "semver", 1145 + ] 1146 + 1147 + [[package]] 1055 1148 name = "rustls" 1056 1149 version = "0.23.37" 1057 1150 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1105 1198 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1106 1199 1107 1200 [[package]] 1201 + name = "semver" 1202 + version = "1.0.27" 1203 + source = "registry+https://github.com/rust-lang/crates.io-index" 1204 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 1205 + 1206 + [[package]] 1108 1207 name = "serde" 1109 1208 version = "1.0.228" 1110 1209 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1160 1259 ] 1161 1260 1162 1261 [[package]] 1262 + name = "sha2" 1263 + version = "0.10.9" 1264 + source = "registry+https://github.com/rust-lang/crates.io-index" 1265 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 1266 + dependencies = [ 1267 + "cfg-if", 1268 + "cpufeatures", 1269 + "digest", 1270 + ] 1271 + 1272 + [[package]] 1163 1273 name = "shlex" 1164 1274 version = "1.3.0" 1165 1275 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1744 1854 checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 1745 1855 1746 1856 [[package]] 1857 + name = "x25519-dalek" 1858 + version = "2.0.1" 1859 + source = "registry+https://github.com/rust-lang/crates.io-index" 1860 + checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" 1861 + dependencies = [ 1862 + "curve25519-dalek", 1863 + "rand_core 0.6.4", 1864 + "serde", 1865 + "zeroize", 1866 + ] 1867 + 1868 + [[package]] 1747 1869 name = "yoke" 1748 1870 version = "0.8.1" 1749 1871 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1812 1934 version = "1.8.2" 1813 1935 source = "registry+https://github.com/rust-lang/crates.io-index" 1814 1936 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 1937 + dependencies = [ 1938 + "zeroize_derive", 1939 + ] 1940 + 1941 + [[package]] 1942 + name = "zeroize_derive" 1943 + version = "1.4.3" 1944 + source = "registry+https://github.com/rust-lang/crates.io-index" 1945 + checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" 1946 + dependencies = [ 1947 + "proc-macro2", 1948 + "quote", 1949 + "syn", 1950 + ] 1815 1951 1816 1952 [[package]] 1817 1953 name = "zerotrie"
+5 -5
crates/opake-core/Cargo.toml
··· 11 11 thiserror.workspace = true 12 12 13 13 aes-gcm = "0.10" 14 + x25519-dalek = { version = "2", features = ["static_secrets"] } 15 + aes-kw = { version = "0.2", features = ["alloc"] } 16 + hkdf = "0.12" # HKDF key derivation (RFC 5869) 17 + sha2 = "0.10" # SHA-256 hash — used by HKDF internally 18 + base64 = "0.22" # atproto $bytes encoding 14 19 15 20 # aes-gcm pulls in getrandom transitively. On wasm32, getrandom needs the 16 21 # "js" feature to use crypto.getRandomValues() instead of OS-level randomness. 17 22 [target.'cfg(target_arch = "wasm32")'.dependencies] 18 23 getrandom = { version = "0.2", features = ["js"] } 19 - 20 - # Add when implementing key wrapping (Phase 1, #4): 21 - # x25519-dalek = "2" # ECDH key agreement for key wrapping — WASM-safe 22 - # rand = "0.10" # key generation 23 - # base64 = "0.22" # atproto bytes encoding
+234 -30
crates/opake-core/src/crypto.rs
··· 1 1 // Client-side encryption primitives. 2 2 // 3 3 // This module handles AES-256-GCM content encryption and asymmetric key 4 - // wrapping. It intentionally has no I/O — it takes bytes in and returns bytes 5 - // out. The calling layer (CLI or WASM) handles reading/writing files and 6 - // talking to the PDS. 4 + // wrapping (x25519-hkdf-a256kw). It intentionally has no I/O — it takes 5 + // bytes in and returns bytes out. The calling layer (CLI or WASM) handles 6 + // reading/writing files and talking to the PDS. 7 7 // 8 8 // Randomness is injected via CryptoRng + RngCore parameters so the module 9 9 // stays platform-agnostic — native callers pass OsRng, WASM callers pass ··· 13 13 aead::{Aead, AeadCore, KeyInit}, 14 14 Aes256Gcm, Key, Nonce, 15 15 }; 16 + use aes_kw::KekAes256; 17 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 18 + use hkdf::Hkdf; 19 + use sha2::Sha256; 20 + use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; 16 21 17 22 use crate::error::Error; 18 - use crate::records::WrappedKey; 23 + use crate::records::{AtBytes, WrappedKey, SCHEMA_VERSION}; 19 24 20 25 /// Re-export so callers don't need a direct rand_core dependency. 21 26 pub use aes_gcm::aead::rand_core::{CryptoRng, RngCore}; 22 27 28 + const WRAP_ALGO: &str = "x25519-hkdf-a256kw"; 29 + const CONTENT_KEY_LEN: usize = 32; 30 + const AES_GCM_NONCE_LEN: usize = 12; 31 + const X25519_KEY_LEN: usize = 32; 32 + const AES_KW_OVERHEAD: usize = 8; 33 + const WRAPPED_KEY_LEN: usize = CONTENT_KEY_LEN + AES_KW_OVERHEAD; 34 + const CIPHERTEXT_LEN: usize = X25519_KEY_LEN + WRAPPED_KEY_LEN; 35 + 23 36 /// A 256-bit AES content encryption key. 24 - pub struct ContentKey(pub [u8; 32]); 37 + pub struct ContentKey(pub [u8; CONTENT_KEY_LEN]); 38 + 39 + /// An X25519 public key: 32 raw bytes. 40 + pub type X25519PublicKey = [u8; X25519_KEY_LEN]; 41 + 42 + /// An X25519 private key: 32 raw bytes. 43 + pub type X25519PrivateKey = [u8; X25519_KEY_LEN]; 44 + 45 + /// A DID string paired with its X25519 public key. 46 + pub type DidPublicKey<'a> = (&'a str, &'a X25519PublicKey); 25 47 26 48 /// The result of encrypting plaintext content. 27 49 pub struct EncryptedPayload { 28 50 pub ciphertext: Vec<u8>, 29 - pub nonce: [u8; 12], 51 + pub nonce: [u8; AES_GCM_NONCE_LEN], 30 52 } 53 + 54 + /// HKDF info string for domain separation — includes schema version so a 55 + /// version bump produces different derived keys from the same shared secret. 56 + fn hkdf_info() -> Vec<u8> { 57 + format!("opake-key-wrap-v{SCHEMA_VERSION}").into_bytes() 58 + } 59 + 60 + // --------------------------------------------------------------------------- 61 + // Content encryption (AES-256-GCM) 62 + // --------------------------------------------------------------------------- 31 63 32 64 /// Generate a random AES-256-GCM content key. 33 65 pub fn generate_content_key(rng: &mut (impl CryptoRng + RngCore)) -> ContentKey { ··· 60 92 .map_err(|e| Error::Decryption(e.to_string())) 61 93 } 62 94 63 - /// Wrap a content key to a recipient's public key (ECDH-ES+A256KW). 95 + // --------------------------------------------------------------------------- 96 + // Key wrapping (x25519-hkdf-a256kw) 97 + // --------------------------------------------------------------------------- 98 + // 99 + // Ciphertext layout: [32 bytes ephemeral X25519 pubkey || 40 bytes AES-KW wrapped content key] 100 + // 101 + // Flow (wrap): 102 + // 1. Generate ephemeral X25519 keypair 103 + // 2. ECDH: shared_secret = X25519(ephemeral_private, recipient_public) 104 + // 3. KDF: wrapping_key = HKDF-SHA256(shared_secret, info="opake-key-wrap-v1") 105 + // 4. Wrap: wrapped = AES-256-KW(wrapping_key, content_key) 106 + // 5. Pack: ciphertext = ephemeral_public || wrapped 107 + // 108 + // Flow (unwrap): reverse — split ciphertext, ECDH with stored private key, 109 + // same KDF, AES-KW unwrap. 110 + 111 + /// Derive a 256-bit wrapping key from an ECDH shared secret via HKDF-SHA256. 112 + fn derive_wrapping_key(shared_secret: &[u8; 32]) -> Result<[u8; 32], Error> { 113 + let hkdf = Hkdf::<Sha256>::new(None, shared_secret); 114 + let mut wrapping_key = [0u8; 32]; 115 + hkdf.expand(&hkdf_info(), &mut wrapping_key) 116 + .map_err(|_| Error::KeyWrap("HKDF expand failed".into()))?; 117 + Ok(wrapping_key) 118 + } 119 + 120 + /// Wrap a content key to a recipient's X25519 public key. 121 + /// 122 + /// Returns a `WrappedKey` whose `ciphertext` contains the ephemeral public key 123 + /// and AES-KW wrapped content key, base64-encoded for atproto storage. 64 124 pub fn wrap_key( 65 - _content_key: &ContentKey, 66 - _recipient_public_key: &[u8], 67 - _recipient_did: &str, 125 + content_key: &ContentKey, 126 + recipient_public_key: &X25519PublicKey, 127 + recipient_did: &str, 128 + rng: &mut (impl CryptoRng + RngCore), 68 129 ) -> Result<WrappedKey, Error> { 69 - unimplemented!("key wrapping requires x25519-dalek — tracked in #4") 130 + let ephemeral_secret = EphemeralSecret::random_from_rng(rng); 131 + let ephemeral_public_key = PublicKey::from(&ephemeral_secret); 132 + 133 + let recipient_public_key = PublicKey::from(*recipient_public_key); 134 + let shared_secret = ephemeral_secret.diffie_hellman(&recipient_public_key); 135 + 136 + let wrapping_key = derive_wrapping_key(shared_secret.as_bytes())?; 137 + let kek = KekAes256::new((&wrapping_key).into()); 138 + let wrapped = kek 139 + .wrap_vec(&content_key.0) 140 + .map_err(|_| Error::KeyWrap("AES key wrap failed".into()))?; 141 + 142 + let mut ciphertext = Vec::with_capacity(CIPHERTEXT_LEN); 143 + ciphertext.extend_from_slice(ephemeral_public_key.as_bytes()); 144 + ciphertext.extend_from_slice(&wrapped); 145 + 146 + Ok(WrappedKey { 147 + did: recipient_did.to_string(), 148 + ciphertext: AtBytes { 149 + encoded: BASE64.encode(&ciphertext), 150 + }, 151 + algo: WRAP_ALGO.to_string(), 152 + }) 70 153 } 71 154 72 - /// Unwrap a content key using the local private key. 73 - pub fn unwrap_key(_wrapped: &WrappedKey, _private_key: &[u8]) -> Result<ContentKey, Error> { 74 - unimplemented!("key unwrapping requires x25519-dalek — tracked in #4") 155 + /// Unwrap a content key using the recipient's X25519 private key. 156 + pub fn unwrap_key( 157 + wrapped: &WrappedKey, 158 + private_key: &X25519PrivateKey, 159 + ) -> Result<ContentKey, Error> { 160 + let ciphertext = BASE64 161 + .decode(&wrapped.ciphertext.encoded) 162 + .map_err(|e| Error::Decryption(format!("base64 decode: {e}")))?; 163 + 164 + if ciphertext.len() != CIPHERTEXT_LEN { 165 + return Err(Error::Decryption(format!( 166 + "invalid ciphertext length: expected {CIPHERTEXT_LEN}, got {}", 167 + ciphertext.len() 168 + ))); 169 + } 170 + 171 + let mut ephemeral_public_key_bytes = [0u8; X25519_KEY_LEN]; 172 + ephemeral_public_key_bytes.copy_from_slice(&ciphertext[..X25519_KEY_LEN]); 173 + let ephemeral_public_key = PublicKey::from(ephemeral_public_key_bytes); 174 + let wrapped_key_bytes = &ciphertext[X25519_KEY_LEN..]; 175 + 176 + let secret = StaticSecret::from(*private_key); 177 + let shared_secret = secret.diffie_hellman(&ephemeral_public_key); 178 + 179 + let wrapping_key = derive_wrapping_key(shared_secret.as_bytes())?; 180 + let kek = KekAes256::new((&wrapping_key).into()); 181 + let content_key_bytes = kek 182 + .unwrap_vec(wrapped_key_bytes) 183 + .map_err(|_| Error::Decryption("AES key unwrap failed".into()))?; 184 + 185 + if content_key_bytes.len() != CONTENT_KEY_LEN { 186 + return Err(Error::Decryption(format!( 187 + "unwrapped key wrong length: expected {CONTENT_KEY_LEN}, got {}", 188 + content_key_bytes.len() 189 + ))); 190 + } 191 + 192 + let mut key = [0u8; CONTENT_KEY_LEN]; 193 + key.copy_from_slice(&content_key_bytes); 194 + Ok(ContentKey(key)) 75 195 } 76 196 77 - /// Generate a random group key for a keyring, then wrap it to a set of DIDs. 197 + /// Generate a random group key for a keyring, then wrap it to each member's public key. 78 198 pub fn create_group_key( 79 - _member_public_keys: &[(&str, &[u8])], 199 + member_public_keys: &[DidPublicKey], 200 + rng: &mut (impl CryptoRng + RngCore), 80 201 ) -> Result<(ContentKey, Vec<WrappedKey>), Error> { 81 - unimplemented!("group key creation requires x25519-dalek — tracked in #4") 202 + let group_key = generate_content_key(rng); 203 + let wrapped_keys: Result<Vec<_>, _> = member_public_keys 204 + .iter() 205 + .map(|(did, pubkey)| wrap_key(&group_key, pubkey, did, rng)) 206 + .collect(); 207 + Ok((group_key, wrapped_keys?)) 82 208 } 83 209 84 210 /// Wrap a per-document content key under a keyring's group key (symmetric wrapping). ··· 101 227 mod tests { 102 228 use super::*; 103 229 use aes_gcm::aead::rand_core::OsRng; 230 + 231 + // -- Content encryption tests (AES-256-GCM) -- 104 232 105 233 #[test] 106 234 fn roundtrip_encrypt_decrypt() { ··· 168 296 assert!(decrypt_blob(&key, &payload).is_err()); 169 297 } 170 298 171 - // Red tests — will pass when #4 (key wrapping) is implemented. 299 + // -- Key wrapping tests (x25519-hkdf-a256kw) -- 300 + 301 + fn test_keypair() -> (StaticSecret, PublicKey) { 302 + let private = StaticSecret::random_from_rng(&mut OsRng); 303 + let public = PublicKey::from(&private); 304 + (private, public) 305 + } 172 306 173 307 #[test] 174 - #[should_panic(expected = "not implemented")] 175 - fn test_wrap_key_roundtrips() { 308 + fn wrap_unwrap_roundtrips() { 176 309 let content_key = generate_content_key(&mut OsRng); 177 - let fake_pubkey = [0u8; 32]; 178 - let wrapped = wrap_key(&content_key, &fake_pubkey, "did:plc:test").unwrap(); 179 - let fake_privkey = [0u8; 32]; 180 - let unwrapped = unwrap_key(&wrapped, &fake_privkey).unwrap(); 310 + let (private, public) = test_keypair(); 311 + 312 + let wrapped = 313 + wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 314 + let unwrapped = unwrap_key(&wrapped, &private.to_bytes()).unwrap(); 315 + 181 316 assert_eq!(content_key.0, unwrapped.0); 182 317 } 183 318 184 319 #[test] 185 - #[should_panic(expected = "not implemented")] 186 - fn test_create_group_key_wraps_to_all_members() { 187 - let members: Vec<(&str, &[u8])> = 188 - vec![("did:plc:alice", &[1u8; 32]), ("did:plc:bob", &[2u8; 32])]; 189 - let (_group_key, wrapped_keys) = create_group_key(&members).unwrap(); 320 + fn wrap_produces_correct_algo() { 321 + let content_key = generate_content_key(&mut OsRng); 322 + let (_private, public) = test_keypair(); 323 + 324 + let wrapped = 325 + wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 326 + assert_eq!(wrapped.algo, "x25519-hkdf-a256kw"); 327 + assert_eq!(wrapped.did, "did:plc:test"); 328 + } 329 + 330 + #[test] 331 + fn wrap_ciphertext_is_expected_length() { 332 + let content_key = generate_content_key(&mut OsRng); 333 + let (_private, public) = test_keypair(); 334 + 335 + let wrapped = 336 + wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 337 + let decoded = BASE64.decode(&wrapped.ciphertext.encoded).unwrap(); 338 + assert_eq!(decoded.len(), CIPHERTEXT_LEN); 339 + } 340 + 341 + #[test] 342 + fn wrong_private_key_fails_unwrap() { 343 + let content_key = generate_content_key(&mut OsRng); 344 + let (_private, public) = test_keypair(); 345 + let (wrong_private, _) = test_keypair(); 346 + 347 + let wrapped = 348 + wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 349 + assert!(unwrap_key(&wrapped, &wrong_private.to_bytes()).is_err()); 350 + } 351 + 352 + #[test] 353 + fn tampered_wrapped_ciphertext_fails_unwrap() { 354 + let content_key = generate_content_key(&mut OsRng); 355 + let (private, public) = test_keypair(); 356 + 357 + let mut wrapped = 358 + wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 359 + let mut bytes = BASE64.decode(&wrapped.ciphertext.encoded).unwrap(); 360 + bytes[40] ^= 0xff; 361 + wrapped.ciphertext.encoded = BASE64.encode(&bytes); 362 + 363 + assert!(unwrap_key(&wrapped, &private.to_bytes()).is_err()); 364 + } 365 + 366 + #[test] 367 + fn each_wrap_produces_unique_ciphertext() { 368 + let content_key = generate_content_key(&mut OsRng); 369 + let (_private, public) = test_keypair(); 370 + 371 + let a = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 372 + let b = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 373 + assert_ne!(a.ciphertext.encoded, b.ciphertext.encoded); 374 + } 375 + 376 + #[test] 377 + fn create_group_key_wraps_to_all_members() { 378 + let (priv_a, pub_a) = test_keypair(); 379 + let (priv_b, pub_b) = test_keypair(); 380 + 381 + let members: Vec<(&str, &[u8; 32])> = vec![ 382 + ("did:plc:alice", pub_a.as_bytes()), 383 + ("did:plc:bob", pub_b.as_bytes()), 384 + ]; 385 + 386 + let (group_key, wrapped_keys) = create_group_key(&members, &mut OsRng).unwrap(); 190 387 assert_eq!(wrapped_keys.len(), 2); 388 + assert_eq!(wrapped_keys[0].did, "did:plc:alice"); 389 + assert_eq!(wrapped_keys[1].did, "did:plc:bob"); 390 + 391 + let unwrapped_a = unwrap_key(&wrapped_keys[0], &priv_a.to_bytes()).unwrap(); 392 + let unwrapped_b = unwrap_key(&wrapped_keys[1], &priv_b.to_bytes()).unwrap(); 393 + assert_eq!(group_key.0, unwrapped_a.0); 394 + assert_eq!(group_key.0, unwrapped_b.0); 191 395 } 192 396 193 - // Red tests — will pass when #16 (keyring wrapping) is implemented. 397 + // -- Keyring wrapping (red tests — #16) -- 194 398 195 399 #[test] 196 400 #[should_panic(expected = "not implemented")]
+26
crates/opake-core/src/records.rs
··· 5 5 6 6 use serde::{Deserialize, Serialize}; 7 7 8 + use crate::error::Error; 9 + 10 + /// The current app.opake.cloud.* schema version this client understands. 11 + /// Records with version <= this are compatible; higher versions must be rejected. 12 + pub const SCHEMA_VERSION: u32 = 1; 13 + 14 + fn default_version() -> u32 { 15 + SCHEMA_VERSION 16 + } 17 + 18 + /// Reject records written by a newer schema version than this client understands. 19 + pub fn check_version(record_version: u32) -> Result<(), Error> { 20 + if record_version > SCHEMA_VERSION { 21 + return Err(Error::InvalidRecord(format!( 22 + "record schema version {record_version} is newer than supported version {SCHEMA_VERSION}" 23 + ))); 24 + } 25 + Ok(()) 26 + } 27 + 8 28 // --------------------------------------------------------------------------- 9 29 // AT Protocol primitives 10 30 // --------------------------------------------------------------------------- ··· 101 121 #[derive(Debug, Clone, Serialize, Deserialize)] 102 122 #[serde(rename_all = "camelCase")] 103 123 pub struct Document { 124 + #[serde(default = "default_version")] 125 + pub version: u32, 104 126 pub name: String, 105 127 #[serde(skip_serializing_if = "Option::is_none")] 106 128 pub mime_type: Option<String>, ··· 128 150 #[derive(Debug, Clone, Serialize, Deserialize)] 129 151 #[serde(rename_all = "camelCase")] 130 152 pub struct Grant { 153 + #[serde(default = "default_version")] 154 + pub version: u32, 131 155 pub document: String, 132 156 pub recipient: String, 133 157 pub wrapped_key: WrappedKey, ··· 147 171 #[derive(Debug, Clone, Serialize, Deserialize)] 148 172 #[serde(rename_all = "camelCase")] 149 173 pub struct Keyring { 174 + #[serde(default = "default_version")] 175 + pub version: u32, 150 176 pub name: String, 151 177 #[serde(skip_serializing_if = "Option::is_none")] 152 178 pub description: Option<String>,
+1 -2
lexicons/app.opake.cloud.defs.json
··· 21 21 "type": "string", 22 22 "description": "Asymmetric algorithm used for key wrapping.", 23 23 "knownValues": [ 24 - "ECDH-ES+A256KW", 25 - "x25519-xsalsa20-poly1305" 24 + "x25519-hkdf-a256kw" 26 25 ] 27 26 } 28 27 }
+6 -1
lexicons/app.opake.cloud.document.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "blob", "encryption", "createdAt"], 11 + "required": ["version", "name", "blob", "encryption", "createdAt"], 12 12 "properties": { 13 + "version": { 14 + "type": "integer", 15 + "description": "Schema version for the app.opake.cloud.* namespace. Clients should reject records with a version they do not understand.", 16 + "minimum": 1 17 + }, 13 18 "name": { 14 19 "type": "string", 15 20 "description": "Human-readable filename or title. Plaintext — intentionally unencrypted for indexing/search by your own AppView.",
+6 -1
lexicons/app.opake.cloud.grant.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["document", "recipient", "wrappedKey", "createdAt"], 11 + "required": ["version", "document", "recipient", "wrappedKey", "createdAt"], 12 12 "properties": { 13 + "version": { 14 + "type": "integer", 15 + "description": "Schema version for the app.opake.cloud.* namespace. Clients should reject records with a version they do not understand.", 16 + "minimum": 1 17 + }, 13 18 "document": { 14 19 "type": "string", 15 20 "format": "at-uri",
+6 -1
lexicons/app.opake.cloud.keyring.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "algo", "members", "createdAt"], 11 + "required": ["version", "name", "algo", "members", "createdAt"], 12 12 "properties": { 13 + "version": { 14 + "type": "integer", 15 + "description": "Schema version for the app.opake.cloud.* namespace. Clients should reject records with a version they do not understand.", 16 + "minimum": 1 17 + }, 13 18 "name": { 14 19 "type": "string", 15 20 "description": "Human-readable name for this keyring (e.g. 'family-photos', 'work-projects').",