Skip to main content

cellos_core/crypto/
mod.rs

1//! Pluggable cryptographic provider port (ADR-0027).
2//!
3//! This module defines the **seam** between cellos's signing/verification logic
4//! and the underlying primitive implementation. Everything here operates in
5//! raw bytes — no `ed25519_dalek` or `sha2` types leak across the boundary — so
6//! a FIPS-validated module (S05, aws-lc-rs) or an HSM-backed path can be slotted
7//! in behind the same trait without touching call sites.
8//!
9//! The default build wires the [`dalek`] adapter (S03): the existing
10//! ed25519-dalek + sha2 logic, byte-for-byte unchanged, with
11//! [`ProviderIdentity::fips_mode`] = `false`. No call site is rewired by this
12//! tranche (S02/S03 are additive); the refactor onto [`provider`] is S04.
13//!
14//! ## Object safety
15//!
16//! [`CryptoProvider`] is deliberately object-safe (all methods take `&self`,
17//! use only sized concrete parameter/return types, and have no generic
18//! parameters) so it can be held as `&dyn CryptoProvider` / `Arc<dyn
19//! CryptoProvider>` and selected at runtime by build configuration.
20
21/// Default software adapter (S03) — compiled out of a pure-FIPS build (C09).
22#[cfg(feature = "dalek")]
23pub mod dalek;
24/// FIPS adapter (S05) — only compiled under `--features fips`.
25#[cfg(feature = "fips")]
26pub mod fips;
27
28// C09: a build must select at least one crypto provider. `default = ["dalek"]`
29// makes this automatic; a `--no-default-features` build without `fips` is the
30// only way to trip it, and that is a configuration error, not a silent no-op.
31#[cfg(all(not(feature = "dalek"), not(feature = "fips")))]
32compile_error!(
33    "cellos-core needs a crypto provider: enable the `dalek` (default) or `fips` feature"
34);
35
36/// Identity of the active [`CryptoProvider`] — surfaced into receipts so an
37/// offline auditor can see *which* module produced a signature.
38///
39/// IMPORTANT: `fips_mode` / `module_cert` are a **producer claim**, not a
40/// validated attestation. The default (dalek) build reports `fips_mode =
41/// false`; a FIPS build (S05) reports `true` and a build-configured cert
42/// string. Nothing here measures or proves the running module — see ADR-0027.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ProviderIdentity {
45    /// Stable provider name, e.g. `"dalek"` or `"aws-lc-fips"`.
46    pub name: &'static str,
47    /// Whether this provider is the FIPS-mode adapter. `false` on the default
48    /// build. A producer claim only (ADR-0027).
49    pub fips_mode: bool,
50    /// CMVP certificate reference for the validated module, when known
51    /// (build-configured). `None` on the default build. A producer claim only;
52    /// cellos does not validate the cert at runtime.
53    pub module_cert: Option<&'static str>,
54}
55
56/// Raw-bytes cryptographic primitive port (ADR-0027).
57///
58/// All inputs and outputs are byte slices / fixed arrays so no
59/// implementation-specific type crosses the boundary. Implementations must be
60/// drop-in interchangeable for the same algorithm and key material.
61pub trait CryptoProvider: Send + Sync {
62    /// Identity of this provider (name + FIPS-mode producer claim).
63    fn identity(&self) -> ProviderIdentity;
64
65    /// Sign `message` with an Ed25519 signing key given as its raw 32-byte seed
66    /// (the dalek `SigningKey::from_bytes` representation). Returns the 64-byte
67    /// signature.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if `seed` is not exactly 32 bytes or the underlying
72    /// module rejects the operation.
73    fn sign_ed25519(&self, seed: &[u8], message: &[u8]) -> Result<[u8; 64], CryptoError>;
74
75    /// Derive the raw 32-byte Ed25519 public key from a 32-byte signing-key
76    /// `seed`, using THIS provider's module (C05). Lets the power-on self-test
77    /// and any seed-based derive stay within the active provider — so a FIPS
78    /// build derives via aws-lc-rs, never via dalek.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if `seed` is not exactly 32 bytes or the module rejects
83    /// the derive.
84    fn public_key_from_seed(&self, seed: &[u8]) -> Result<[u8; 32], CryptoError>;
85
86    /// Validate that `public_key` is a structurally valid Ed25519 verifying key
87    /// (C06). The default (dalek) provider performs the strict load-time decode
88    /// (`VerifyingKey::from_bytes`: on-curve + canonical encoding). The FIPS
89    /// provider documents a residual — aws-lc-rs exposes no standalone point
90    /// validator, so structural rejection is deferred to verify-time, which the
91    /// `c06_aws_lc_rejects_every_key_dalek_rejects_at_load` test proves is
92    /// equivalent for *rejection* (a malformed key is inert, never verifies).
93    ///
94    /// # Errors
95    ///
96    /// Returns [`CryptoError`] when the bytes are not a usable Ed25519 public
97    /// key (length, or — on the dalek provider — off-curve / non-canonical).
98    fn validate_ed25519_public_key(&self, public_key: &[u8]) -> Result<(), CryptoError>;
99
100    /// Verify a 64-byte Ed25519 `signature` over `message` against a raw
101    /// 32-byte `public_key`. Uses strict verification semantics (rejects
102    /// non-canonical encodings / small-order points), matching
103    /// `VerifyingKey::verify_strict`.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the public key or signature is the wrong length, or
108    /// if verification fails.
109    fn verify_ed25519(
110        &self,
111        public_key: &[u8],
112        message: &[u8],
113        signature: &[u8],
114    ) -> Result<(), CryptoError>;
115
116    /// Compute HMAC-SHA-256 (RFC 2104 / FIPS 198) of `message` under `key`.
117    /// Returns the 32-byte MAC.
118    fn hmac_sha256(&self, key: &[u8], message: &[u8]) -> [u8; 32];
119
120    /// Constant-time equality over two byte slices. Returns `false` for
121    /// unequal lengths (without an early-exit branch on the contents).
122    fn constant_time_eq(&self, a: &[u8], b: &[u8]) -> bool;
123
124    /// Compute the SHA-256 digest of `message`. Returns the 32-byte digest.
125    fn sha256(&self, message: &[u8]) -> [u8; 32];
126}
127
128/// A trust-anchor Ed25519 public key as raw 32 bytes.
129///
130/// This is the in-memory keyring value type used across cellos's signed-event
131/// and signed-trust-keyset verifiers (S04, ADR-0027). It deliberately replaces
132/// the previous `ed25519_dalek::VerifyingKey` leak so that no implementation
133/// type crosses a public boundary outside this `crypto` module — a
134/// FIPS-validated provider (S05) can be slotted in without changing any keyring
135/// signature.
136///
137/// **Load-time validity.** A `TrustAnchorPublicKey` should be constructed via
138/// [`TrustAnchorPublicKey::from_validated_bytes`], which routes the point check
139/// through the active provider (C06): on the default build dalek's strict
140/// `VerifyingKey::from_bytes` rejects a malformed / non-canonical point at load;
141/// under `--features fips` rejection is deferred to verify-time (documented
142/// residual — see [`CryptoProvider::validate_ed25519_public_key`]). The raw
143/// constructor [`TrustAnchorPublicKey::from_bytes_unchecked`] is available for
144/// call sites that have already validated the bytes.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub struct TrustAnchorPublicKey([u8; 32]);
147
148impl TrustAnchorPublicKey {
149    /// Wrap raw 32 bytes after validating them as a canonical Ed25519 public
150    /// key via the active provider's adapter check.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`CryptoError::VerifyFailed`] when the bytes are not a valid
155    /// Ed25519 verifying key (non-canonical encoding, small-order point, etc.).
156    pub fn from_validated_bytes(bytes: [u8; 32]) -> Result<Self, CryptoError> {
157        // C06: route through the active provider so a FIPS-pure build validates
158        // without a dalek call. On the default build this is dalek's strict
159        // `VerifyingKey::from_bytes`; under fips it is the documented verify-time
160        // residual (see CryptoProvider::validate_ed25519_public_key).
161        provider().validate_ed25519_public_key(&bytes)?;
162        Ok(Self(bytes))
163    }
164
165    /// Wrap raw 32 bytes without re-validating. Use only when the bytes have
166    /// already been proven to be a valid Ed25519 public key (e.g. derived from
167    /// a known-good seed via [`dalek::public_key_from_seed`]).
168    pub fn from_bytes_unchecked(bytes: [u8; 32]) -> Self {
169        Self(bytes)
170    }
171
172    /// Borrow the raw 32-byte public key for handing to
173    /// [`CryptoProvider::verify_ed25519`].
174    pub fn as_bytes(&self) -> &[u8; 32] {
175        &self.0
176    }
177}
178
179/// Error surface for [`CryptoProvider`] operations.
180///
181/// Kept distinct from [`crate::error::CellosError`] so the crypto port has no
182/// dependency on the broader domain error enum; call sites convert as needed.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum CryptoError {
185    /// A key or signature had the wrong byte length for the algorithm.
186    BadLength {
187        /// What the bytes were meant to be (e.g. `"ed25519 seed"`).
188        what: &'static str,
189        /// Expected length in bytes.
190        expected: usize,
191        /// Actual length supplied.
192        got: usize,
193    },
194    /// Signature verification failed (bad signature, wrong key, non-canonical
195    /// encoding under strict verification, etc.).
196    VerifyFailed(String),
197}
198
199impl core::fmt::Display for CryptoError {
200    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
201        match self {
202            CryptoError::BadLength {
203                what,
204                expected,
205                got,
206            } => write!(f, "{what} must be {expected} bytes, got {got}"),
207            CryptoError::VerifyFailed(msg) => write!(f, "verify failed: {msg}"),
208        }
209    }
210}
211
212impl std::error::Error for CryptoError {}
213
214impl From<CryptoError> for crate::error::CellosError {
215    fn from(e: CryptoError) -> Self {
216        crate::error::CellosError::InvalidSpec(format!("crypto provider: {e}"))
217    }
218}
219
220/// Return the active [`CryptoProvider`] for this build.
221///
222/// On the default build this is the [`dalek`] adapter (S03). A FIPS build (S05)
223/// returns the aws-lc-rs adapter instead. Returned as `&'static dyn` so callers
224/// can hold it without ownership concerns; the provider is stateless.
225pub fn provider() -> &'static dyn CryptoProvider {
226    // S05: under `--features fips` the CMVP-validated aws-lc-fips adapter is the
227    // active provider; the default build uses dalek. Wire bytes are identical
228    // (Ed25519 / HMAC-SHA-256 / SHA-256 over the S01 canonical payload).
229    #[cfg(feature = "fips")]
230    {
231        &fips::FipsProvider
232    }
233    #[cfg(all(not(feature = "fips"), feature = "dalek"))]
234    {
235        &dalek::DalekProvider
236    }
237}
238
239/// Return the [`ProviderIdentity`] of the active provider.
240///
241/// Convenience for receipt-stamping call sites that only need the identity.
242/// On the default build `fips_mode == false`.
243pub fn provider_identity() -> ProviderIdentity {
244    provider().identity()
245}
246
247/// Power-on self-test (S09, ADR-0027): exercise the active provider's Ed25519
248/// sign+verify, HMAC-SHA-256, and SHA-256 before any signing path is trusted.
249/// Returns `Err` if a primitive is broken or a tampered message verifies — a
250/// hardened/fips startup MUST fail-stop on `Err` rather than sign with an
251/// unproven module.
252///
253/// C05: the public key is derived via the ACTIVE provider's
254/// [`public_key_from_seed`](CryptoProvider::public_key_from_seed), so the FIPS
255/// build's self-test stays entirely within aws-lc-rs (no dalek call on the fips
256/// POST path); the default build derives via dalek, byte-identical to before.
257pub fn power_on_self_test() -> Result<(), CryptoError> {
258    let p = provider();
259    let seed = [0x42u8; 32];
260    let msg = b"cellos-crypto-power-on-self-test";
261    let sig = p.sign_ed25519(&seed, msg)?;
262    let public = p.public_key_from_seed(&seed)?;
263    p.verify_ed25519(&public, msg, &sig)?;
264    if p.verify_ed25519(&public, b"tampered-post", &sig).is_ok() {
265        return Err(CryptoError::VerifyFailed(
266            "power-on self-test: a tampered message verified".into(),
267        ));
268    }
269    // HMAC + digest must be deterministic (a non-deterministic primitive is a
270    // broken module).
271    if p.hmac_sha256(b"post-key", msg) != p.hmac_sha256(b"post-key", msg)
272        || p.sha256(msg) != p.sha256(msg)
273    {
274        return Err(CryptoError::VerifyFailed(
275            "power-on self-test: hmac/sha256 not deterministic".into(),
276        ));
277    }
278    Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[cfg(not(feature = "fips"))]
286    #[test]
287    fn default_provider_is_non_fips() {
288        let id = provider_identity();
289        assert!(!id.fips_mode, "default build must not claim FIPS mode");
290        assert_eq!(id.name, "dalek");
291        assert_eq!(id.module_cert, None);
292    }
293
294    /// Under `--features fips` the active provider is the CMVP-validated adapter
295    /// and reports `fips_mode = true` (a producer claim — see `fips.rs`).
296    #[cfg(feature = "fips")]
297    #[test]
298    fn fips_feature_selects_fips_provider() {
299        let id = provider_identity();
300        assert!(id.fips_mode, "fips build must claim FIPS mode");
301        assert_eq!(id.name, "aws-lc-fips");
302    }
303
304    #[test]
305    fn trait_is_object_safe() {
306        // If `CryptoProvider` were not object-safe this would not compile.
307        let p: &dyn CryptoProvider = provider();
308        // Name is provider-specific (dalek vs aws-lc-fips); assert it is present
309        // rather than pinning a feature-specific value.
310        assert!(!p.identity().name.is_empty());
311    }
312
313    #[test]
314    fn power_on_self_test_passes_for_active_provider() {
315        super::power_on_self_test().expect("POST must pass for a working provider");
316    }
317
318    /// C05: the active provider derives a public key from a seed that verifies
319    /// its own signatures — exercised feature-agnostically so the FIPS build
320    /// proves its derive path stays inside aws-lc-rs.
321    #[test]
322    fn provider_public_key_from_seed_round_trips() {
323        let p = provider();
324        let seed = [0x11u8; 32];
325        let public = p.public_key_from_seed(&seed).expect("provider derive");
326        let msg = b"c05-provider-derive";
327        let sig = p.sign_ed25519(&seed, msg).expect("sign");
328        p.verify_ed25519(&public, msg, &sig)
329            .expect("provider-derived key verifies the provider's own signature");
330    }
331
332    #[test]
333    fn provider_public_key_from_seed_rejects_bad_length() {
334        assert!(
335            provider().public_key_from_seed(&[0u8; 31]).is_err(),
336            "a 31-byte seed must be rejected"
337        );
338    }
339
340    /// Property sweep: over many deterministic pseudo-random (seed, message)
341    /// pairs, the active provider must sign→verify round-trip, and BOTH a
342    /// one-bit message tamper and a one-bit signature tamper must fail. Runs on
343    /// the default (dalek) and fips providers alike.
344    #[test]
345    fn provider_sign_verify_property_sweep() {
346        let p = provider();
347        for i in 0u32..256 {
348            let seed = p.sha256(&i.to_le_bytes());
349            let pk = p.public_key_from_seed(&seed).expect("derive");
350            let msg = p.sha256(&(i ^ 0xa5a5_a5a5).to_le_bytes());
351            let sig = p.sign_ed25519(&seed, &msg).expect("sign");
352
353            p.verify_ed25519(&pk, &msg, &sig)
354                .unwrap_or_else(|_| panic!("round-trip must verify (i={i})"));
355
356            // One-bit message tamper.
357            let mut m2 = msg;
358            m2[i as usize % 32] ^= 1;
359            assert!(
360                p.verify_ed25519(&pk, &m2, &sig).is_err(),
361                "message tamper must fail (i={i})"
362            );
363
364            // One-bit signature tamper.
365            let mut s2 = sig;
366            s2[i as usize % 64] ^= 1;
367            assert!(
368                p.verify_ed25519(&pk, &msg, &s2).is_err(),
369                "signature tamper must fail (i={i})"
370            );
371        }
372    }
373
374    /// C06: on the default (dalek) build, `from_validated_bytes` still rejects a
375    /// structurally invalid key at load (strict `VerifyingKey::from_bytes`).
376    #[cfg(not(feature = "fips"))]
377    #[test]
378    fn from_validated_bytes_is_strict_on_default_build() {
379        let p = provider();
380        let good = p.public_key_from_seed(&[5u8; 32]).unwrap();
381        assert!(
382            TrustAnchorPublicKey::from_validated_bytes(good).is_ok(),
383            "a valid derived key loads"
384        );
385        let mut rejected = false;
386        for i in 0u32..1000 {
387            let cand = p.sha256(&i.to_le_bytes());
388            if TrustAnchorPublicKey::from_validated_bytes(cand).is_err() {
389                rejected = true;
390                break;
391            }
392        }
393        assert!(
394            rejected,
395            "default build must reject some malformed keys at load"
396        );
397    }
398
399    /// C06 residual (fips build): `from_validated_bytes` enforces length but
400    /// defers point-validation to verify-time — a key dalek rejects at load may
401    /// still load, then is inert (proven by the fips `c06_aws_lc_rejects...`
402    /// test). This pins the documented degradation so it cannot change silently.
403    /// Needs both providers present to compare against dalek's strict validator.
404    #[cfg(all(feature = "fips", feature = "dalek"))]
405    #[test]
406    fn from_validated_bytes_defers_to_verify_time_under_fips() {
407        let p = provider();
408        // Wrong length is still rejected up front.
409        assert!(p.validate_ed25519_public_key(&[0u8; 31]).is_err());
410        // A key dalek's strict validator rejects still loads under fips (residual).
411        let mut found_residual = false;
412        for i in 0u32..1000 {
413            let cand = p.sha256(&i.to_le_bytes());
414            if crate::crypto::dalek::validate_ed25519_public_key(&cand).is_err() {
415                assert!(
416                    TrustAnchorPublicKey::from_validated_bytes(cand).is_ok(),
417                    "fips residual: structurally-invalid key loads (rejection deferred to verify)"
418                );
419                found_residual = true;
420                break;
421            }
422        }
423        assert!(
424            found_residual,
425            "expected a dalek-rejected candidate to demonstrate the residual"
426        );
427    }
428
429    /// Doc-style round-trip exercised as a unit test: sign then verify through
430    /// the trait object.
431    #[test]
432    fn ed25519_sign_verify_round_trip_through_trait() {
433        let p = provider();
434        let seed = [7u8; 32];
435        // C05: derive via the active provider's port (no dalek call here).
436        let public = p.public_key_from_seed(&seed).expect("derive pub");
437        let msg = b"canonical-payload-bytes";
438        let sig = p.sign_ed25519(&seed, msg).expect("sign");
439        p.verify_ed25519(&public, msg, &sig).expect("verify ok");
440
441        // Tampered message fails.
442        assert!(p.verify_ed25519(&public, b"other", &sig).is_err());
443    }
444}