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}