1#![forbid(unsafe_code)]
17
18use aes_gcm::aead::{Aead as _, KeyInit as _, Payload};
19use aes_gcm::{Aes256Gcm, Nonce as AesNonce};
20use basil::proto::{AeadAlgorithm, CiphertextEnvelope};
21use chacha20poly1305::{ChaCha20Poly1305, Nonce as ChaChaNonce};
22use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
23use rand::RngCore as _;
24use zeroize::Zeroizing;
25
26#[cfg(feature = "onepassword")]
27mod onepassword;
28pub mod store;
29
30pub use store::{SecretStore, StoreConfig, StoreError};
31
32const KEY_LEN: usize = 32;
33const ED25519_SIG_LEN: usize = 64;
34const NONCE_LEN: usize = 12;
35const KEYSTORE_VERSION: u32 = 1;
36const AEAD_AAD_PREFIX: &[u8] = b"basil-keystore-aead-v1";
37
38#[derive(Debug, thiserror::Error)]
40pub enum CryptoError {
41 #[error("invalid key length: expected {expected} bytes, got {actual}")]
43 BadKeyLength {
44 expected: usize,
46 actual: usize,
48 },
49 #[error("invalid signature length: expected {expected} bytes, got {actual}")]
51 BadSignatureLength {
52 expected: usize,
54 actual: usize,
56 },
57 #[error("invalid nonce length: expected {expected} bytes, got {actual}")]
59 BadNonceLength {
60 expected: usize,
62 actual: usize,
64 },
65 #[error("unsupported AEAD algorithm: {0}")]
67 UnsupportedAlgorithm(AeadAlgorithm),
68 #[error("encrypt failed")]
70 EncryptFailed,
71 #[error("decrypt failed")]
73 DecryptFailed,
74}
75
76fn key_from_slice(bytes: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, CryptoError> {
77 let key = bytes.try_into().map_err(|_| CryptoError::BadKeyLength {
78 expected: KEY_LEN,
79 actual: bytes.len(),
80 })?;
81 Ok(Zeroizing::new(key))
82}
83
84fn envelope_aad(
85 algorithm: AeadAlgorithm,
86 key_version: u32,
87 external_aad: Option<&[u8]>,
88) -> Vec<u8> {
89 let external_aad = external_aad.unwrap_or_default();
90 let algorithm = match algorithm {
91 AeadAlgorithm::Chacha20Poly1305 => 1u8,
92 AeadAlgorithm::Aes256Gcm => 2u8,
93 };
94 let mut aad = Vec::with_capacity(
95 AEAD_AAD_PREFIX.len() + 1 + core::mem::size_of::<u32>() + external_aad.len(),
96 );
97 aad.extend_from_slice(AEAD_AAD_PREFIX);
98 aad.push(algorithm);
99 aad.extend_from_slice(&key_version.to_le_bytes());
100 aad.extend_from_slice(external_aad);
101 aad
102}
103
104pub fn sign_ed25519(seed: &[u8], message: &[u8]) -> Result<[u8; ED25519_SIG_LEN], CryptoError> {
110 let seed = key_from_slice(seed)?;
111 let key = SigningKey::from_bytes(&seed);
112 Ok(key.sign(message).to_bytes())
113}
114
115pub fn public_ed25519(seed: &[u8]) -> Result<[u8; KEY_LEN], CryptoError> {
121 let seed = key_from_slice(seed)?;
122 let key = SigningKey::from_bytes(&seed);
123 Ok(key.verifying_key().to_bytes())
124}
125
126pub fn verify_ed25519(
133 public: &[u8],
134 message: &[u8],
135 signature: &[u8],
136) -> Result<bool, CryptoError> {
137 let public: [u8; KEY_LEN] = public.try_into().map_err(|_| CryptoError::BadKeyLength {
138 expected: KEY_LEN,
139 actual: public.len(),
140 })?;
141 let signature: [u8; ED25519_SIG_LEN] =
142 signature
143 .try_into()
144 .map_err(|_| CryptoError::BadSignatureLength {
145 expected: ED25519_SIG_LEN,
146 actual: signature.len(),
147 })?;
148 let Ok(verifying_key) = VerifyingKey::from_bytes(&public) else {
149 return Ok(false);
150 };
151 let signature = Signature::from_bytes(&signature);
152 Ok(verifying_key.verify(message, &signature).is_ok())
153}
154
155pub fn encrypt_aead(
172 key: &[u8],
173 algorithm: AeadAlgorithm,
174 plaintext: &[u8],
175 aad: Option<&[u8]>,
176) -> Result<CiphertextEnvelope, CryptoError> {
177 let key = key_from_slice(key)?;
178 let mut nonce = [0u8; NONCE_LEN];
179 rand::thread_rng().fill_bytes(&mut nonce);
180 let aad = envelope_aad(algorithm, KEYSTORE_VERSION, aad);
181 let ciphertext = match algorithm {
182 AeadAlgorithm::Aes256Gcm => Aes256Gcm::new_from_slice(key.as_slice())
183 .map_err(|_| CryptoError::EncryptFailed)?
184 .encrypt(
185 &AesNonce::from(nonce),
186 Payload {
187 msg: plaintext,
188 aad: &aad,
189 },
190 )
191 .map_err(|_| CryptoError::EncryptFailed)?,
192 AeadAlgorithm::Chacha20Poly1305 => ChaCha20Poly1305::new_from_slice(key.as_slice())
193 .map_err(|_| CryptoError::EncryptFailed)?
194 .encrypt(
195 &ChaChaNonce::from(nonce),
196 Payload {
197 msg: plaintext,
198 aad: &aad,
199 },
200 )
201 .map_err(|_| CryptoError::EncryptFailed)?,
202 };
203 Ok(CiphertextEnvelope {
204 alg: algorithm,
205 key_version: KEYSTORE_VERSION,
206 nonce: nonce.to_vec(),
207 ciphertext,
208 })
209}
210
211pub fn decrypt_aead(
217 key: &[u8],
218 envelope: &CiphertextEnvelope,
219 aad: Option<&[u8]>,
220) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
221 let key = key_from_slice(key)?;
222 let nonce: [u8; NONCE_LEN] =
223 envelope
224 .nonce
225 .as_slice()
226 .try_into()
227 .map_err(|_| CryptoError::BadNonceLength {
228 expected: NONCE_LEN,
229 actual: envelope.nonce.len(),
230 })?;
231 let aad = envelope_aad(envelope.alg, envelope.key_version, aad);
232 let plaintext = match envelope.alg {
233 AeadAlgorithm::Aes256Gcm => Aes256Gcm::new_from_slice(key.as_slice())
234 .map_err(|_| CryptoError::DecryptFailed)?
235 .decrypt(
236 &AesNonce::from(nonce),
237 Payload {
238 msg: envelope.ciphertext.as_slice(),
239 aad: &aad,
240 },
241 )
242 .map_err(|_| CryptoError::DecryptFailed)?,
243 AeadAlgorithm::Chacha20Poly1305 => ChaCha20Poly1305::new_from_slice(key.as_slice())
244 .map_err(|_| CryptoError::DecryptFailed)?
245 .decrypt(
246 &ChaChaNonce::from(nonce),
247 Payload {
248 msg: envelope.ciphertext.as_slice(),
249 aad: &aad,
250 },
251 )
252 .map_err(|_| CryptoError::DecryptFailed)?,
253 };
254 Ok(Zeroizing::new(plaintext))
255}
256
257#[must_use]
259pub fn generate_key_material() -> Zeroizing<[u8; KEY_LEN]> {
260 let mut key = Zeroizing::new([0u8; KEY_LEN]);
261 rand::thread_rng().fill_bytes(key.as_mut_slice());
262 key
263}
264
265#[must_use]
267pub const fn keystore_version() -> u32 {
268 KEYSTORE_VERSION
269}
270
271#[cfg(test)]
272mod tests {
273 #![allow(
274 clippy::unwrap_used,
275 clippy::expect_used,
276 clippy::indexing_slicing,
277 clippy::panic
278 )]
279
280 use super::{
281 AeadAlgorithm, CryptoError, decrypt_aead, encrypt_aead, generate_key_material,
282 public_ed25519, sign_ed25519, verify_ed25519,
283 };
284
285 const KEY_A: [u8; 32] = [
287 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
288 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
289 0x1e, 0x1f,
290 ];
291 const KEY_B: [u8; 32] = [0xa5; 32];
293
294 const BOTH_ALGS: [AeadAlgorithm; 2] =
295 [AeadAlgorithm::Aes256Gcm, AeadAlgorithm::Chacha20Poly1305];
296
297 #[test]
298 fn ed25519_sign_verify_round_trip() {
299 let message = b"materialize-to-use ed25519 round trip";
300 let signature = sign_ed25519(&KEY_A, message).unwrap();
301 let public = public_ed25519(&KEY_A).unwrap();
302 assert!(
303 verify_ed25519(&public, message, &signature).unwrap(),
304 "a signature must verify under the seed's own public"
305 );
306 }
307
308 #[test]
309 fn ed25519_verify_rejects_wrong_key() {
310 let message = b"authentic message";
311 let signature = sign_ed25519(&KEY_A, message).unwrap();
312 let wrong_public = public_ed25519(&KEY_B).unwrap();
314 assert!(
315 !verify_ed25519(&wrong_public, message, &signature).unwrap(),
316 "a signature must fail closed under a foreign public key"
317 );
318 }
319
320 #[test]
321 fn ed25519_verify_rejects_tampered_message() {
322 let signature = sign_ed25519(&KEY_A, b"original message").unwrap();
323 let public = public_ed25519(&KEY_A).unwrap();
324 assert!(
325 !verify_ed25519(&public, b"tampered message", &signature).unwrap(),
326 "a signature must not verify over a different message"
327 );
328 }
329
330 #[test]
331 fn ed25519_verify_rejects_tampered_signature() {
332 let message = b"a message to sign";
333 let mut signature = sign_ed25519(&KEY_A, message).unwrap();
334 let public = public_ed25519(&KEY_A).unwrap();
335 signature[0] ^= 0x01;
336 assert!(
337 !verify_ed25519(&public, message, &signature).unwrap(),
338 "a flipped signature bit must fail closed"
339 );
340 }
341
342 #[test]
343 fn ed25519_is_deterministic() {
344 let message = b"determinism check";
345 let first = sign_ed25519(&KEY_A, message).unwrap();
346 let second = sign_ed25519(&KEY_A, message).unwrap();
347 assert_eq!(first, second, "Ed25519 signatures are deterministic");
348 }
349
350 #[test]
351 fn ed25519_sign_bad_seed_length() {
352 let err = sign_ed25519(&[0u8; 31], b"x").unwrap_err();
353 assert!(matches!(
354 err,
355 CryptoError::BadKeyLength {
356 expected: 32,
357 actual: 31
358 }
359 ));
360 }
361
362 #[test]
363 fn public_ed25519_bad_seed_length() {
364 let err = public_ed25519(&[0u8; 33]).unwrap_err();
365 assert!(matches!(
366 err,
367 CryptoError::BadKeyLength {
368 expected: 32,
369 actual: 33
370 }
371 ));
372 }
373
374 #[test]
375 fn verify_ed25519_bad_lengths() {
376 let signature = sign_ed25519(&KEY_A, b"x").unwrap();
377 let public = public_ed25519(&KEY_A).unwrap();
378 assert!(matches!(
379 verify_ed25519(&public[..31], b"x", &signature),
380 Err(CryptoError::BadKeyLength { expected: 32, .. })
381 ));
382 assert!(matches!(
383 verify_ed25519(&public, b"x", &signature[..63]),
384 Err(CryptoError::BadSignatureLength { expected: 64, .. })
385 ));
386 }
387
388 #[test]
389 fn verify_ed25519_non_canonical_public_is_false_not_error() {
390 let signature = sign_ed25519(&KEY_A, b"x").unwrap();
393 assert!(
394 !verify_ed25519(&[0xff; 32], b"x", &signature).unwrap(),
395 "a non-canonical public verifies false, not Err"
396 );
397 }
398
399 #[test]
400 fn aead_round_trip_both_algorithms() {
401 let plaintext = b"broker-owned-nonce AEAD round trip";
402 for alg in BOTH_ALGS {
403 let envelope = encrypt_aead(&KEY_A, alg, plaintext, None).unwrap();
404 assert_eq!(envelope.alg, alg);
405 assert_eq!(envelope.nonce.len(), 12, "Basil owns a 12-byte nonce");
406 assert_ne!(
407 envelope.ciphertext.as_slice(),
408 plaintext.as_slice(),
409 "ciphertext must not equal plaintext"
410 );
411 let recovered = decrypt_aead(&KEY_A, &envelope, None).unwrap();
412 assert_eq!(recovered.as_slice(), plaintext.as_slice());
413 }
414 }
415
416 #[test]
417 fn aead_round_trip_with_aad() {
418 let plaintext = b"payload";
419 let aad = b"bound associated data";
420 for alg in BOTH_ALGS {
421 let envelope = encrypt_aead(&KEY_A, alg, plaintext, Some(aad)).unwrap();
422 let recovered = decrypt_aead(&KEY_A, &envelope, Some(aad)).unwrap();
423 assert_eq!(recovered.as_slice(), plaintext.as_slice());
424 }
425 }
426
427 #[test]
428 fn aead_wrong_key_fails_closed() {
429 let plaintext = b"secret";
430 for alg in BOTH_ALGS {
431 let envelope = encrypt_aead(&KEY_A, alg, plaintext, None).unwrap();
432 let err = decrypt_aead(&KEY_B, &envelope, None).unwrap_err();
433 assert!(matches!(err, CryptoError::DecryptFailed));
434 }
435 }
436
437 #[test]
438 fn aead_tampered_ciphertext_fails_closed() {
439 let plaintext = b"secret";
440 for alg in BOTH_ALGS {
441 let mut envelope = encrypt_aead(&KEY_A, alg, plaintext, None).unwrap();
442 envelope.ciphertext[0] ^= 0x01;
443 let err = decrypt_aead(&KEY_A, &envelope, None).unwrap_err();
444 assert!(matches!(err, CryptoError::DecryptFailed));
445 }
446 }
447
448 #[test]
449 fn aead_wrong_aad_fails_closed() {
450 let plaintext = b"secret";
451 for alg in BOTH_ALGS {
452 let envelope = encrypt_aead(&KEY_A, alg, plaintext, Some(b"aad-one")).unwrap();
453 let err = decrypt_aead(&KEY_A, &envelope, Some(b"aad-two")).unwrap_err();
455 assert!(matches!(err, CryptoError::DecryptFailed));
456 let err = decrypt_aead(&KEY_A, &envelope, None).unwrap_err();
458 assert!(matches!(err, CryptoError::DecryptFailed));
459 }
460 }
461
462 #[test]
463 fn aead_nonce_is_fresh_per_encrypt() {
464 let plaintext = b"same plaintext";
465 let first = encrypt_aead(&KEY_A, AeadAlgorithm::Aes256Gcm, plaintext, None).unwrap();
466 let second = encrypt_aead(&KEY_A, AeadAlgorithm::Aes256Gcm, plaintext, None).unwrap();
467 assert_ne!(
468 first.nonce, second.nonce,
469 "each encrypt must draw a fresh broker-owned nonce"
470 );
471 assert_ne!(
472 first.ciphertext, second.ciphertext,
473 "a fresh nonce yields distinct ciphertext for identical plaintext"
474 );
475 }
476
477 #[test]
478 fn encrypt_bad_key_length_fails() {
479 let err = encrypt_aead(&[0u8; 16], AeadAlgorithm::Aes256Gcm, b"x", None).unwrap_err();
480 assert!(matches!(
481 err,
482 CryptoError::BadKeyLength {
483 expected: 32,
484 actual: 16
485 }
486 ));
487 }
488
489 #[test]
490 fn decrypt_bad_nonce_length_fails() {
491 let mut envelope =
492 encrypt_aead(&KEY_A, AeadAlgorithm::Aes256Gcm, b"payload", None).unwrap();
493 envelope.nonce.truncate(11);
494 let err = decrypt_aead(&KEY_A, &envelope, None).unwrap_err();
495 assert!(matches!(
496 err,
497 CryptoError::BadNonceLength {
498 expected: 12,
499 actual: 11
500 }
501 ));
502 }
503
504 #[test]
505 fn cross_algorithm_ciphertext_does_not_decrypt() {
506 let mut envelope =
509 encrypt_aead(&KEY_A, AeadAlgorithm::Aes256Gcm, b"payload", None).unwrap();
510 envelope.alg = AeadAlgorithm::Chacha20Poly1305;
511 let err = decrypt_aead(&KEY_A, &envelope, None).unwrap_err();
512 assert!(matches!(err, CryptoError::DecryptFailed));
513 }
514
515 #[test]
516 fn aead_key_version_is_authenticated() {
517 let plaintext = b"versioned secret";
518 for alg in BOTH_ALGS {
519 let mut envelope = encrypt_aead(&KEY_A, alg, plaintext, Some(b"context")).unwrap();
520 envelope.key_version += 1;
521 let err = decrypt_aead(&KEY_A, &envelope, Some(b"context")).unwrap_err();
522 assert!(matches!(err, CryptoError::DecryptFailed));
523 }
524 }
525
526 #[test]
527 fn generate_key_material_is_random_32_bytes() {
528 let a = generate_key_material();
529 let b = generate_key_material();
530 assert_eq!(a.len(), 32);
531 assert_ne!(
532 a.as_slice(),
533 b.as_slice(),
534 "fresh key material must not repeat"
535 );
536 }
537}