this repo has no description
1
fork

Configure Feed

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

Implement AES-256-GCM content encryption and decryption

RNG is injected via CryptoRng + RngCore parameters to keep
opake-core platform-agnostic — callers provide their own
randomness source (OsRng on native, crypto.getRandomValues on
WASM). Added conditional getrandom/js dep for wasm32 targets.

Documents multi-device key management as an open architectural
decision (CLAUDE.md #7) to be resolved before keystore work.

+327 -24
+2
CHANGELOG.md
··· 12 12 ### Fixed 13 13 14 14 ### Changed 15 + - Add AES-256-GCM content encryption and decryption (#3) 16 + - Fix WASM compilation for opake-core by enabling getrandom js feature (#27) 15 17 - Add PDS authentication via com.atproto.server.createSession (#2)
+7
CLAUDE.md
··· 164 164 165 165 6. **50MB blob limit is fine for now.** Covers documents, photos, and short media. Large file support (video, archives) can come later via a sidecar service similar to how Tangled uses "knots" alongside the PDS. 166 166 167 + 7. **Multi-device key management: TBD.** The user's encryption keypair must be available on every device that needs to decrypt files. Three options under consideration — choice affects UX significantly: 168 + - **(A) Key export/import** — User manually transfers an encrypted private key between devices. Simple to implement (git-crypt model), worst UX. Requires `opake export-key` / `opake import-key` commands. 169 + - **(B) Multi-device keys** — Each device generates its own keypair and registers its public key in the DID document. Content keys get re-wrapped to all device keys. No key material leaves a device (most secure), but requires re-wrapping on device addition. 170 + - **(C) Recovery seed** — Derive the keypair deterministically from a BIP-39-style mnemonic. Same seed on any device produces the same key. Best UX, but a leaked seed compromises everything. 171 + 172 + Decision deferred until #9 (local keystore) is closer to implementation. UX matters here — this is the first thing a user hits after install. 173 + 167 174 ## File Structure 168 175 169 176 ```
+156 -2
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "aead" 7 + version = "0.5.2" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" 10 + dependencies = [ 11 + "crypto-common", 12 + "generic-array", 13 + ] 14 + 15 + [[package]] 16 + name = "aes" 17 + version = "0.8.4" 18 + source = "registry+https://github.com/rust-lang/crates.io-index" 19 + checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" 20 + dependencies = [ 21 + "cfg-if", 22 + "cipher", 23 + "cpufeatures", 24 + ] 25 + 26 + [[package]] 27 + name = "aes-gcm" 28 + version = "0.10.3" 29 + source = "registry+https://github.com/rust-lang/crates.io-index" 30 + checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" 31 + dependencies = [ 32 + "aead", 33 + "aes", 34 + "cipher", 35 + "ctr", 36 + "ghash", 37 + "subtle", 38 + ] 39 + 40 + [[package]] 6 41 name = "aho-corasick" 7 42 version = "1.1.4" 8 43 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 120 155 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 121 156 122 157 [[package]] 158 + name = "cipher" 159 + version = "0.4.4" 160 + source = "registry+https://github.com/rust-lang/crates.io-index" 161 + checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" 162 + dependencies = [ 163 + "crypto-common", 164 + "inout", 165 + ] 166 + 167 + [[package]] 123 168 name = "clap" 124 169 version = "4.5.60" 125 170 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 164 209 version = "1.0.4" 165 210 source = "registry+https://github.com/rust-lang/crates.io-index" 166 211 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 212 + 213 + [[package]] 214 + name = "cpufeatures" 215 + version = "0.2.17" 216 + source = "registry+https://github.com/rust-lang/crates.io-index" 217 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 218 + dependencies = [ 219 + "libc", 220 + ] 221 + 222 + [[package]] 223 + name = "crypto-common" 224 + version = "0.1.7" 225 + source = "registry+https://github.com/rust-lang/crates.io-index" 226 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 227 + dependencies = [ 228 + "generic-array", 229 + "rand_core 0.6.4", 230 + "typenum", 231 + ] 232 + 233 + [[package]] 234 + name = "ctr" 235 + version = "0.9.2" 236 + source = "registry+https://github.com/rust-lang/crates.io-index" 237 + checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" 238 + dependencies = [ 239 + "cipher", 240 + ] 167 241 168 242 [[package]] 169 243 name = "displaydoc" ··· 258 332 ] 259 333 260 334 [[package]] 335 + name = "generic-array" 336 + version = "0.14.7" 337 + source = "registry+https://github.com/rust-lang/crates.io-index" 338 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 339 + dependencies = [ 340 + "typenum", 341 + "version_check", 342 + ] 343 + 344 + [[package]] 261 345 name = "getrandom" 262 346 version = "0.2.17" 263 347 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 282 366 "r-efi", 283 367 "wasip2", 284 368 "wasm-bindgen", 369 + ] 370 + 371 + [[package]] 372 + name = "ghash" 373 + version = "0.5.1" 374 + source = "registry+https://github.com/rust-lang/crates.io-index" 375 + checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" 376 + dependencies = [ 377 + "opaque-debug", 378 + "polyval", 285 379 ] 286 380 287 381 [[package]] ··· 493 587 ] 494 588 495 589 [[package]] 590 + name = "inout" 591 + version = "0.1.4" 592 + source = "registry+https://github.com/rust-lang/crates.io-index" 593 + checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" 594 + dependencies = [ 595 + "generic-array", 596 + ] 597 + 598 + [[package]] 496 599 name = "ipnet" 497 600 version = "2.11.0" 498 601 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 635 738 name = "opake-core" 636 739 version = "0.1.0" 637 740 dependencies = [ 741 + "aes-gcm", 742 + "getrandom 0.2.17", 638 743 "log", 639 744 "serde", 640 745 "serde_json", 641 746 "thiserror", 642 747 ] 748 + 749 + [[package]] 750 + name = "opaque-debug" 751 + version = "0.3.1" 752 + source = "registry+https://github.com/rust-lang/crates.io-index" 753 + checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 643 754 644 755 [[package]] 645 756 name = "parking_lot" ··· 683 794 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 684 795 685 796 [[package]] 797 + name = "polyval" 798 + version = "0.6.2" 799 + source = "registry+https://github.com/rust-lang/crates.io-index" 800 + checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" 801 + dependencies = [ 802 + "cfg-if", 803 + "cpufeatures", 804 + "opaque-debug", 805 + "universal-hash", 806 + ] 807 + 808 + [[package]] 686 809 name = "portable-atomic" 687 810 version = "1.13.1" 688 811 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 801 924 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 802 925 dependencies = [ 803 926 "rand_chacha", 804 - "rand_core", 927 + "rand_core 0.9.5", 805 928 ] 806 929 807 930 [[package]] ··· 811 934 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 812 935 dependencies = [ 813 936 "ppv-lite86", 814 - "rand_core", 937 + "rand_core 0.9.5", 938 + ] 939 + 940 + [[package]] 941 + name = "rand_core" 942 + version = "0.6.4" 943 + source = "registry+https://github.com/rust-lang/crates.io-index" 944 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 945 + dependencies = [ 946 + "getrandom 0.2.17", 815 947 ] 816 948 817 949 [[package]] ··· 1268 1400 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1269 1401 1270 1402 [[package]] 1403 + name = "typenum" 1404 + version = "1.19.0" 1405 + source = "registry+https://github.com/rust-lang/crates.io-index" 1406 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 1407 + 1408 + [[package]] 1271 1409 name = "unicode-ident" 1272 1410 version = "1.0.24" 1273 1411 source = "registry+https://github.com/rust-lang/crates.io-index" 1274 1412 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 1413 + 1414 + [[package]] 1415 + name = "universal-hash" 1416 + version = "0.5.1" 1417 + source = "registry+https://github.com/rust-lang/crates.io-index" 1418 + checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" 1419 + dependencies = [ 1420 + "crypto-common", 1421 + "subtle", 1422 + ] 1275 1423 1276 1424 [[package]] 1277 1425 name = "untrusted" ··· 1302 1450 version = "0.2.2" 1303 1451 source = "registry+https://github.com/rust-lang/crates.io-index" 1304 1452 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1453 + 1454 + [[package]] 1455 + name = "version_check" 1456 + version = "0.9.5" 1457 + source = "registry+https://github.com/rust-lang/crates.io-index" 1458 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1305 1459 1306 1460 [[package]] 1307 1461 name = "want"
+9 -3
crates/opake-core/Cargo.toml
··· 10 10 serde_json.workspace = true 11 11 thiserror.workspace = true 12 12 13 - # Add when implementing crypto (Phase 1): 14 - # aes-gcm = "0.10" # AES-256-GCM content encryption 13 + aes-gcm = "0.10" 14 + 15 + # aes-gcm pulls in getrandom transitively. On wasm32, getrandom needs the 16 + # "js" feature to use crypto.getRandomValues() instead of OS-level randomness. 17 + [target.'cfg(target_arch = "wasm32")'.dependencies] 18 + getrandom = { version = "0.2", features = ["js"] } 19 + 20 + # Add when implementing key wrapping (Phase 1, #4): 15 21 # x25519-dalek = "2" # ECDH key agreement for key wrapping — WASM-safe 16 - # rand = "0.8" # key generation 22 + # rand = "0.10" # key generation 17 23 # base64 = "0.22" # atproto bytes encoding
+153 -19
crates/opake-core/src/crypto.rs
··· 4 4 // wrapping. It intentionally has no I/O — it takes bytes in and returns bytes 5 5 // out. The calling layer (CLI or WASM) handles reading/writing files and 6 6 // talking to the PDS. 7 + // 8 + // Randomness is injected via CryptoRng + RngCore parameters so the module 9 + // stays platform-agnostic — native callers pass OsRng, WASM callers pass 10 + // a crypto.getRandomValues()-backed RNG. 11 + 12 + use aes_gcm::{ 13 + aead::{Aead, AeadCore, KeyInit}, 14 + Aes256Gcm, Key, Nonce, 15 + }; 7 16 8 17 use crate::error::Error; 9 18 use crate::records::WrappedKey; 10 19 20 + /// Re-export so callers don't need a direct rand_core dependency. 21 + pub use aes_gcm::aead::rand_core::{CryptoRng, RngCore}; 22 + 11 23 /// A 256-bit AES content encryption key. 12 24 pub struct ContentKey(pub [u8; 32]); 13 25 ··· 18 30 } 19 31 20 32 /// Generate a random AES-256-GCM content key. 21 - pub fn generate_content_key() -> ContentKey { 22 - todo!() 33 + pub fn generate_content_key(rng: &mut (impl CryptoRng + RngCore)) -> ContentKey { 34 + ContentKey(Aes256Gcm::generate_key(rng).into()) 23 35 } 24 36 25 37 /// Encrypt plaintext bytes with a content key (AES-256-GCM). 26 - pub fn encrypt_blob(key: &ContentKey, plaintext: &[u8]) -> Result<EncryptedPayload, Error> { 27 - todo!() 38 + pub fn encrypt_blob( 39 + key: &ContentKey, 40 + plaintext: &[u8], 41 + rng: &mut (impl CryptoRng + RngCore), 42 + ) -> Result<EncryptedPayload, Error> { 43 + let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key.0)); 44 + let nonce = Aes256Gcm::generate_nonce(rng); 45 + let ciphertext = cipher 46 + .encrypt(&nonce, plaintext) 47 + .map_err(|e| Error::Encryption(e.to_string()))?; 48 + Ok(EncryptedPayload { 49 + ciphertext, 50 + nonce: nonce.into(), 51 + }) 28 52 } 29 53 30 54 /// Decrypt an encrypted payload with a content key. 31 55 pub fn decrypt_blob(key: &ContentKey, payload: &EncryptedPayload) -> Result<Vec<u8>, Error> { 32 - todo!() 56 + let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key.0)); 57 + let nonce = Nonce::from_slice(&payload.nonce); 58 + cipher 59 + .decrypt(nonce, payload.ciphertext.as_ref()) 60 + .map_err(|e| Error::Decryption(e.to_string())) 33 61 } 34 62 35 63 /// Wrap a content key to a recipient's public key (ECDH-ES+A256KW). 36 64 pub fn wrap_key( 37 - content_key: &ContentKey, 38 - recipient_public_key: &[u8], 39 - recipient_did: &str, 65 + _content_key: &ContentKey, 66 + _recipient_public_key: &[u8], 67 + _recipient_did: &str, 40 68 ) -> Result<WrappedKey, Error> { 41 - todo!() 69 + unimplemented!("key wrapping requires x25519-dalek — tracked in #4") 42 70 } 43 71 44 72 /// Unwrap a content key using the local private key. 45 - pub fn unwrap_key(wrapped: &WrappedKey, private_key: &[u8]) -> Result<ContentKey, Error> { 46 - todo!() 73 + pub fn unwrap_key(_wrapped: &WrappedKey, _private_key: &[u8]) -> Result<ContentKey, Error> { 74 + unimplemented!("key unwrapping requires x25519-dalek — tracked in #4") 47 75 } 48 76 49 77 /// Generate a random group key for a keyring, then wrap it to a set of DIDs. 50 78 pub fn create_group_key( 51 - member_public_keys: &[(&str, &[u8])], // (did, pubkey) pairs 79 + _member_public_keys: &[(&str, &[u8])], 52 80 ) -> Result<(ContentKey, Vec<WrappedKey>), Error> { 53 - todo!() 81 + unimplemented!("group key creation requires x25519-dalek — tracked in #4") 54 82 } 55 83 56 84 /// Wrap a per-document content key under a keyring's group key (symmetric wrapping). 57 85 pub fn wrap_content_key_for_keyring( 58 - content_key: &ContentKey, 59 - group_key: &ContentKey, 86 + _content_key: &ContentKey, 87 + _group_key: &ContentKey, 60 88 ) -> Result<Vec<u8>, Error> { 61 - todo!() 89 + unimplemented!("keyring wrapping — tracked in #16") 62 90 } 63 91 64 92 /// Unwrap a per-document content key using the keyring's group key. 65 93 pub fn unwrap_content_key_from_keyring( 66 - wrapped: &[u8], 67 - group_key: &ContentKey, 94 + _wrapped: &[u8], 95 + _group_key: &ContentKey, 68 96 ) -> Result<ContentKey, Error> { 69 - todo!() 97 + unimplemented!("keyring unwrapping — tracked in #16") 98 + } 99 + 100 + #[cfg(test)] 101 + mod tests { 102 + use super::*; 103 + use aes_gcm::aead::rand_core::OsRng; 104 + 105 + #[test] 106 + fn roundtrip_encrypt_decrypt() { 107 + let key = generate_content_key(&mut OsRng); 108 + let plaintext = b"hello opake"; 109 + let payload = encrypt_blob(&key, plaintext, &mut OsRng).unwrap(); 110 + let decrypted = decrypt_blob(&key, &payload).unwrap(); 111 + assert_eq!(decrypted, plaintext); 112 + } 113 + 114 + #[test] 115 + fn wrong_key_fails_decryption() { 116 + let key = generate_content_key(&mut OsRng); 117 + let wrong_key = generate_content_key(&mut OsRng); 118 + let payload = encrypt_blob(&key, b"secret", &mut OsRng).unwrap(); 119 + assert!(decrypt_blob(&wrong_key, &payload).is_err()); 120 + } 121 + 122 + #[test] 123 + fn empty_plaintext_roundtrips() { 124 + let key = generate_content_key(&mut OsRng); 125 + let payload = encrypt_blob(&key, b"", &mut OsRng).unwrap(); 126 + let decrypted = decrypt_blob(&key, &payload).unwrap(); 127 + assert!(decrypted.is_empty()); 128 + } 129 + 130 + #[test] 131 + fn ciphertext_differs_from_plaintext() { 132 + let key = generate_content_key(&mut OsRng); 133 + let plaintext = b"not encrypted i promise"; 134 + let payload = encrypt_blob(&key, plaintext, &mut OsRng).unwrap(); 135 + assert_ne!(payload.ciphertext, plaintext); 136 + } 137 + 138 + #[test] 139 + fn unique_nonces_per_encryption() { 140 + let key = generate_content_key(&mut OsRng); 141 + let a = encrypt_blob(&key, b"same", &mut OsRng).unwrap(); 142 + let b = encrypt_blob(&key, b"same", &mut OsRng).unwrap(); 143 + assert_ne!(a.nonce, b.nonce); 144 + } 145 + 146 + #[test] 147 + fn tampered_ciphertext_fails() { 148 + let key = generate_content_key(&mut OsRng); 149 + let mut payload = encrypt_blob(&key, b"integrity", &mut OsRng).unwrap(); 150 + payload.ciphertext[0] ^= 0xff; 151 + assert!(decrypt_blob(&key, &payload).is_err()); 152 + } 153 + 154 + #[test] 155 + fn large_payload_roundtrips() { 156 + let key = generate_content_key(&mut OsRng); 157 + let plaintext = vec![0xAB_u8; 1_000_000]; 158 + let payload = encrypt_blob(&key, &plaintext, &mut OsRng).unwrap(); 159 + let decrypted = decrypt_blob(&key, &payload).unwrap(); 160 + assert_eq!(decrypted, plaintext); 161 + } 162 + 163 + #[test] 164 + fn tampered_nonce_fails() { 165 + let key = generate_content_key(&mut OsRng); 166 + let mut payload = encrypt_blob(&key, b"nonce matters", &mut OsRng).unwrap(); 167 + payload.nonce[0] ^= 0xff; 168 + assert!(decrypt_blob(&key, &payload).is_err()); 169 + } 170 + 171 + // Red tests — will pass when #4 (key wrapping) is implemented. 172 + 173 + #[test] 174 + #[should_panic(expected = "not implemented")] 175 + fn test_wrap_key_roundtrips() { 176 + 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(); 181 + assert_eq!(content_key.0, unwrapped.0); 182 + } 183 + 184 + #[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(); 190 + assert_eq!(wrapped_keys.len(), 2); 191 + } 192 + 193 + // Red tests — will pass when #16 (keyring wrapping) is implemented. 194 + 195 + #[test] 196 + #[should_panic(expected = "not implemented")] 197 + fn test_keyring_content_key_roundtrips() { 198 + let group_key = generate_content_key(&mut OsRng); 199 + let content_key = generate_content_key(&mut OsRng); 200 + let wrapped = wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 201 + let unwrapped = unwrap_content_key_from_keyring(&wrapped, &group_key).unwrap(); 202 + assert_eq!(content_key.0, unwrapped.0); 203 + } 70 204 }