An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(crypto): implement P-256 key generation and AES-256-GCM encryption (MM-92)

authored by malpercio.dev and committed by

Tangled 022d7fd2 775b9632

+250
+248
crates/crypto/src/keys.rs
··· 1 + use aes_gcm::aead::{Aead, AeadCore, KeyInit}; 2 + use aes_gcm::{Aes256Gcm, Nonce}; 3 + use base64::engine::general_purpose::STANDARD as BASE64; 4 + use base64::Engine; 5 + use multibase::Base; 6 + use p256::elliptic_curve::sec1::ToEncodedPoint; 7 + use p256::SecretKey; 8 + use rand_core::OsRng; 9 + use zeroize::Zeroizing; 10 + 11 + use crate::CryptoError; 12 + 13 + /// P-256 multicodec varint prefix for did:key URIs. 14 + /// 0x1200 encoded as LEB128 varint = [0x80, 0x24]. 15 + const P256_MULTICODEC_PREFIX: &[u8] = &[0x80, 0x24]; 16 + 17 + /// A generated P-256 keypair. 18 + /// 19 + /// `private_key_bytes` is zeroized on drop. Callers must encrypt it with 20 + /// [`encrypt_private_key`] before storing and drop this struct promptly. 21 + pub struct P256Keypair { 22 + /// Full `did:key:z...` URI — use as the database primary key. 23 + pub key_id: String, 24 + /// Multibase base58btc-encoded compressed public key point (no `did:key:` prefix). 25 + pub public_key: String, 26 + /// Raw 32-byte P-256 private key scalar. Zeroized on drop. 27 + pub private_key_bytes: Zeroizing<[u8; 32]>, 28 + } 29 + 30 + /// Generate a fresh P-256 keypair and derive its `did:key` identifier. 31 + pub fn generate_p256_keypair() -> Result<P256Keypair, CryptoError> { 32 + let secret_key = SecretKey::random(&mut OsRng); 33 + let public_key = secret_key.public_key(); 34 + 35 + // Compressed point: 0x02/0x03 prefix byte + 32-byte x-coordinate = 33 bytes. 36 + let compressed = public_key.to_encoded_point(true); 37 + let compressed_bytes = compressed.as_bytes(); 38 + 39 + // did:key multikey: P-256 multicodec varint + compressed public key bytes. 40 + let mut multikey = Vec::with_capacity(P256_MULTICODEC_PREFIX.len() + compressed_bytes.len()); 41 + multikey.extend_from_slice(P256_MULTICODEC_PREFIX); 42 + multikey.extend_from_slice(compressed_bytes); 43 + 44 + // multibase::encode with Base58Btc prepends the 'z' prefix automatically. 45 + let multibase_encoded = multibase::encode(Base::Base58Btc, &multikey); 46 + let key_id = format!("did:key:{multibase_encoded}"); 47 + let public_key_str = multibase::encode(Base::Base58Btc, compressed_bytes); 48 + 49 + // Copy private key bytes into a Zeroizing wrapper. 50 + let raw_bytes = secret_key.to_bytes(); 51 + let mut private_key_bytes = Zeroizing::new([0u8; 32]); 52 + private_key_bytes.copy_from_slice(raw_bytes.as_slice()); 53 + 54 + Ok(P256Keypair { 55 + key_id, 56 + public_key: public_key_str, 57 + private_key_bytes, 58 + }) 59 + } 60 + 61 + /// Encrypt a 32-byte P-256 private key using AES-256-GCM. 62 + /// 63 + /// Returns `base64( nonce(12) || ciphertext+tag(48) )` — always 80 base64 chars. 64 + /// A fresh 12-byte nonce is generated from the OS RNG on every call, so two calls 65 + /// with the same input produce different output (AC3.4). 66 + pub fn encrypt_private_key( 67 + key_bytes: &[u8; 32], 68 + master_key: &[u8; 32], 69 + ) -> Result<String, CryptoError> { 70 + let cipher = Aes256Gcm::new_from_slice(master_key.as_slice()) 71 + .map_err(|e| CryptoError::Encryption(format!("invalid master key length: {e}")))?; 72 + 73 + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); 74 + 75 + // encrypt() appends the 16-byte authentication tag: output = 32 + 16 = 48 bytes. 76 + let ciphertext = cipher 77 + .encrypt(&nonce, key_bytes.as_slice()) 78 + .map_err(|e| CryptoError::Encryption(format!("aes-gcm encryption failed: {e}")))?; 79 + 80 + // Storage format: nonce(12) || ciphertext_with_tag(48) = 60 bytes → 80 base64 chars. 81 + let mut storage = Vec::with_capacity(12 + ciphertext.len()); 82 + storage.extend_from_slice(nonce.as_slice()); 83 + storage.extend_from_slice(&ciphertext); 84 + 85 + Ok(BASE64.encode(&storage)) 86 + } 87 + 88 + /// Decrypt a private key encrypted by [`encrypt_private_key`]. 89 + /// 90 + /// Returns `CryptoError::Decryption` for any failure — malformed base64, wrong 91 + /// length, or authentication tag mismatch. The caller cannot distinguish between 92 + /// these cases intentionally (no oracle). 93 + pub fn decrypt_private_key( 94 + encrypted: &str, 95 + master_key: &[u8; 32], 96 + ) -> Result<Zeroizing<[u8; 32]>, CryptoError> { 97 + let storage = BASE64 98 + .decode(encrypted) 99 + .map_err(|e| CryptoError::Decryption(format!("invalid base64: {e}")))?; 100 + 101 + if storage.len() != 60 { 102 + return Err(CryptoError::Decryption(format!( 103 + "expected 60 bytes (nonce + ciphertext + tag), got {}", 104 + storage.len() 105 + ))); 106 + } 107 + 108 + let nonce = Nonce::from_slice(&storage[..12]); 109 + let ciphertext_with_tag = &storage[12..]; 110 + 111 + let cipher = Aes256Gcm::new_from_slice(master_key.as_slice()) 112 + .map_err(|e| CryptoError::Decryption(format!("invalid master key length: {e}")))?; 113 + 114 + // Use decrypt() — NOT decrypt_in_place_detached — to avoid GHSA-423w-p2w9-r7vq. 115 + // Tag is verified before plaintext is returned. 116 + let plaintext = cipher 117 + .decrypt(nonce, ciphertext_with_tag) 118 + .map_err(|_| CryptoError::Decryption("authentication tag mismatch".to_string()))?; 119 + 120 + let mut out = Zeroizing::new([0u8; 32]); 121 + out.copy_from_slice(&plaintext); 122 + Ok(out) 123 + } 124 + 125 + #[cfg(test)] 126 + mod tests { 127 + use super::*; 128 + 129 + #[test] 130 + fn generate_keypair_produces_valid_did_key() { 131 + let keypair = generate_p256_keypair().unwrap(); 132 + 133 + // key_id must be a valid did:key URI with P-256 multicodec prefix. 134 + assert!( 135 + keypair.key_id.starts_with("did:key:z"), 136 + "key_id must start with did:key:z" 137 + ); 138 + 139 + // Decode the multibase portion and verify the multicodec prefix. 140 + let multibase_part = keypair.key_id.strip_prefix("did:key:").unwrap(); 141 + let (_, multikey_bytes) = multibase::decode(multibase_part).unwrap(); 142 + assert_eq!( 143 + &multikey_bytes[..2], 144 + P256_MULTICODEC_PREFIX, 145 + "multikey must start with P-256 multicodec varint [0x80, 0x24]" 146 + ); 147 + // P-256 compressed point: 2 (prefix varint) + 33 (compressed) = 35 bytes. 148 + assert_eq!(multikey_bytes.len(), 35); 149 + } 150 + 151 + #[test] 152 + fn generate_keypair_public_key_is_multibase_without_did_prefix() { 153 + let keypair = generate_p256_keypair().unwrap(); 154 + 155 + // public_key is multibase (starts with 'z') but has no 'did:key:' prefix. 156 + assert!(keypair.public_key.starts_with('z')); 157 + assert!(!keypair.public_key.starts_with("did:key:")); 158 + 159 + // Decodes to a compressed P-256 point: 33 bytes. 160 + let (_, point_bytes) = multibase::decode(&keypair.public_key).unwrap(); 161 + assert_eq!(point_bytes.len(), 33); 162 + assert!( 163 + point_bytes[0] == 0x02 || point_bytes[0] == 0x03, 164 + "compressed point prefix must be 0x02 or 0x03" 165 + ); 166 + } 167 + 168 + #[test] 169 + fn generate_keypair_private_key_is_32_bytes() { 170 + let keypair = generate_p256_keypair().unwrap(); 171 + assert_eq!(keypair.private_key_bytes.len(), 32); 172 + } 173 + 174 + /// MM-92.AC3.1: Round-trip encrypt → decrypt returns original bytes. 175 + #[test] 176 + fn encrypt_decrypt_round_trip() { 177 + let master_key = [0xab_u8; 32]; 178 + let private_key = [0x42_u8; 32]; 179 + 180 + let encrypted = encrypt_private_key(&private_key, &master_key).unwrap(); 181 + let decrypted = decrypt_private_key(&encrypted, &master_key).unwrap(); 182 + 183 + assert_eq!(*decrypted, private_key); 184 + } 185 + 186 + /// MM-92.AC3.2: Wrong master key returns CryptoError::Decryption. 187 + #[test] 188 + fn decrypt_with_wrong_master_key_fails() { 189 + let master_key = [0xab_u8; 32]; 190 + let wrong_key = [0xcd_u8; 32]; 191 + let private_key = [0x42_u8; 32]; 192 + 193 + let encrypted = encrypt_private_key(&private_key, &master_key).unwrap(); 194 + let result = decrypt_private_key(&encrypted, &wrong_key); 195 + 196 + assert!( 197 + matches!(result, Err(CryptoError::Decryption(_))), 198 + "expected CryptoError::Decryption, got {result:?}" 199 + ); 200 + } 201 + 202 + /// MM-92.AC3.3: Malformed base64 returns CryptoError::Decryption. 203 + #[test] 204 + fn decrypt_invalid_base64_fails() { 205 + let master_key = [0xab_u8; 32]; 206 + 207 + let result = decrypt_private_key("not-valid-base64!!!", &master_key); 208 + assert!(matches!(result, Err(CryptoError::Decryption(_)))); 209 + } 210 + 211 + /// MM-92.AC3.3 (variant): Base64 that decodes but is wrong length. 212 + #[test] 213 + fn decrypt_wrong_length_fails() { 214 + let master_key = [0xab_u8; 32]; 215 + // Valid base64 of 10 bytes — decodes OK but is not 60 bytes. 216 + let short = BASE64.encode(&[0u8; 10]); 217 + let result = decrypt_private_key(&short, &master_key); 218 + assert!(matches!(result, Err(CryptoError::Decryption(_)))); 219 + } 220 + 221 + /// MM-92.AC3.4: Two encryptions of the same key produce different ciphertexts (random nonce). 222 + #[test] 223 + fn encrypt_produces_different_ciphertexts_for_same_input() { 224 + let master_key = [0xab_u8; 32]; 225 + let private_key = [0x42_u8; 32]; 226 + 227 + let first = encrypt_private_key(&private_key, &master_key).unwrap(); 228 + let second = encrypt_private_key(&private_key, &master_key).unwrap(); 229 + 230 + assert_ne!( 231 + first, second, 232 + "random nonce must produce distinct ciphertexts" 233 + ); 234 + } 235 + 236 + #[test] 237 + fn encrypted_output_is_80_base64_chars() { 238 + let master_key = [0xab_u8; 32]; 239 + let private_key = [0x42_u8; 32]; 240 + 241 + let encrypted = encrypt_private_key(&private_key, &master_key).unwrap(); 242 + assert_eq!( 243 + encrypted.len(), 244 + 80, 245 + "base64(60 bytes) must be exactly 80 characters" 246 + ); 247 + } 248 + }
+2
crates/crypto/src/lib.rs
··· 1 1 // crypto: signing, Shamir secret sharing, DID operations. 2 2 3 3 pub mod error; 4 + pub mod keys; 4 5 5 6 pub use error::CryptoError; 7 + pub use keys::{decrypt_private_key, encrypt_private_key, generate_p256_keypair, P256Keypair};