Skip to main content

basil_keystore_backend/
lib.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Optional materialize-to-use key-store support for [Basil](https://github.com/openbasil/basil).
6//!
7//! To use within basil, the basil-bin crate needs to be compiled
8//! with either `db-keystore` or `onepassword` features.
9//! see [Backends & custody](https://docs.openbasil.org/introduction/backends-and-custody/)
10//!
11//! This crate holds the storage adapters and local crypto needed when Basil is
12//! backed by a key/value store rather than an in-place transit engine. Secret
13//! bytes are returned in [`Zeroizing`] owners and local crypto materializes a key
14//! for exactly one operation.
15
16#![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/// Local materialize-to-use crypto failure.
39#[derive(Debug, thiserror::Error)]
40pub enum CryptoError {
41    /// The stored key material had the wrong fixed length.
42    #[error("invalid key length: expected {expected} bytes, got {actual}")]
43    BadKeyLength {
44        /// Required length.
45        expected: usize,
46        /// Actual length.
47        actual: usize,
48    },
49    /// The signature had the wrong fixed length.
50    #[error("invalid signature length: expected {expected} bytes, got {actual}")]
51    BadSignatureLength {
52        /// Required length.
53        expected: usize,
54        /// Actual length.
55        actual: usize,
56    },
57    /// The AEAD envelope nonce had the wrong fixed length.
58    #[error("invalid nonce length: expected {expected} bytes, got {actual}")]
59    BadNonceLength {
60        /// Required length.
61        expected: usize,
62        /// Actual length.
63        actual: usize,
64    },
65    /// The requested AEAD suite is not supported for this operation.
66    #[error("unsupported AEAD algorithm: {0}")]
67    UnsupportedAlgorithm(AeadAlgorithm),
68    /// AEAD encryption failed.
69    #[error("encrypt failed")]
70    EncryptFailed,
71    /// AEAD authentication failed.
72    #[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
104/// Sign `message` as raw Ed25519 using a materialized 32-byte seed.
105///
106/// # Errors
107///
108/// Returns [`CryptoError::BadKeyLength`] when `seed` is not 32 bytes.
109pub 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
115/// Derive the Ed25519 public key from a materialized 32-byte seed.
116///
117/// # Errors
118///
119/// Returns [`CryptoError::BadKeyLength`] when `seed` is not 32 bytes.
120pub 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
126/// Verify an Ed25519 signature with public bytes.
127///
128/// # Errors
129///
130/// Returns a length error when `public` or `signature` has the wrong fixed
131/// length. Non-canonical public keys return `Ok(false)` to avoid an oracle.
132pub 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
155/// Encrypt with a materialized 32-byte AEAD key. Basil owns the nonce.
156///
157/// # Nonce bounds
158///
159/// Each call draws a fresh random 96-bit nonce; nothing tracks how many
160/// messages a key has protected. With random 96-bit nonces the collision
161/// probability stays below the customary 2^-32 target only while a single key
162/// version protects fewer than about 2^32 messages (NIST SP 800-38D section
163/// 8.3). This module does not enforce that bound: operators of high-volume
164/// keys must rotate the backing secret (bumping the envelope `key_version`)
165/// well before a key version approaches 2^32 encryptions.
166///
167/// # Errors
168///
169/// Returns a length error for non-32-byte key material, or
170/// [`CryptoError::EncryptFailed`] if the AEAD implementation rejects the input.
171pub 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
211/// Decrypt with a materialized 32-byte AEAD key.
212///
213/// # Errors
214///
215/// Returns [`CryptoError::DecryptFailed`] for any AEAD authentication failure.
216pub 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/// Generate fresh 32-byte key material for key-store-backed keys.
258#[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/// Key-store-backed crypto uses a fixed single version.
266#[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    /// A deterministic non-trivial 32-byte key for round-trip assertions.
286    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    /// A different 32-byte key, used for the wrong-key fail-closed cases.
292    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        // Public of a DIFFERENT seed must not accept the signature.
313        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        // An all-0xFF public is not a canonical Ed25519 point; the API returns
391        // Ok(false) rather than surfacing an oracle-shaped error.
392        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            // Right key, wrong AAD -> authentication fails.
454            let err = decrypt_aead(&KEY_A, &envelope, Some(b"aad-two")).unwrap_err();
455            assert!(matches!(err, CryptoError::DecryptFailed));
456            // Right key, AAD dropped entirely -> also fails.
457            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        // An AES-GCM envelope opened as ChaCha (its alg field flipped) must fail
507        // closed because the envelope algorithm is authenticated metadata.
508        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}