cellos-core 0.8.0-pre

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! Pluggable cryptographic provider port (ADR-0027).
//!
//! This module defines the **seam** between cellos's signing/verification logic
//! and the underlying primitive implementation. Everything here operates in
//! raw bytes — no `ed25519_dalek` or `sha2` types leak across the boundary — so
//! a FIPS-validated module (S05, aws-lc-rs) or an HSM-backed path can be slotted
//! in behind the same trait without touching call sites.
//!
//! The default build wires the [`dalek`] adapter (S03): the existing
//! ed25519-dalek + sha2 logic, byte-for-byte unchanged, with
//! [`ProviderIdentity::fips_mode`] = `false`. No call site is rewired by this
//! tranche (S02/S03 are additive); the refactor onto [`provider`] is S04.
//!
//! ## Object safety
//!
//! [`CryptoProvider`] is deliberately object-safe (all methods take `&self`,
//! use only sized concrete parameter/return types, and have no generic
//! parameters) so it can be held as `&dyn CryptoProvider` / `Arc<dyn
//! CryptoProvider>` and selected at runtime by build configuration.

/// Default software adapter (S03) — compiled out of a pure-FIPS build (C09).
#[cfg(feature = "dalek")]
pub mod dalek;
/// FIPS adapter (S05) — only compiled under `--features fips`.
#[cfg(feature = "fips")]
pub mod fips;

// C09: a build must select at least one crypto provider. `default = ["dalek"]`
// makes this automatic; a `--no-default-features` build without `fips` is the
// only way to trip it, and that is a configuration error, not a silent no-op.
#[cfg(all(not(feature = "dalek"), not(feature = "fips")))]
compile_error!(
    "cellos-core needs a crypto provider: enable the `dalek` (default) or `fips` feature"
);

/// Identity of the active [`CryptoProvider`] — surfaced into receipts so an
/// offline auditor can see *which* module produced a signature.
///
/// IMPORTANT: `fips_mode` / `module_cert` are a **producer claim**, not a
/// validated attestation. The default (dalek) build reports `fips_mode =
/// false`; a FIPS build (S05) reports `true` and a build-configured cert
/// string. Nothing here measures or proves the running module — see ADR-0027.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderIdentity {
    /// Stable provider name, e.g. `"dalek"` or `"aws-lc-fips"`.
    pub name: &'static str,
    /// Whether this provider is the FIPS-mode adapter. `false` on the default
    /// build. A producer claim only (ADR-0027).
    pub fips_mode: bool,
    /// CMVP certificate reference for the validated module, when known
    /// (build-configured). `None` on the default build. A producer claim only;
    /// cellos does not validate the cert at runtime.
    pub module_cert: Option<&'static str>,
}

/// Raw-bytes cryptographic primitive port (ADR-0027).
///
/// All inputs and outputs are byte slices / fixed arrays so no
/// implementation-specific type crosses the boundary. Implementations must be
/// drop-in interchangeable for the same algorithm and key material.
pub trait CryptoProvider: Send + Sync {
    /// Identity of this provider (name + FIPS-mode producer claim).
    fn identity(&self) -> ProviderIdentity;

    /// Sign `message` with an Ed25519 signing key given as its raw 32-byte seed
    /// (the dalek `SigningKey::from_bytes` representation). Returns the 64-byte
    /// signature.
    ///
    /// # Errors
    ///
    /// Returns an error if `seed` is not exactly 32 bytes or the underlying
    /// module rejects the operation.
    fn sign_ed25519(&self, seed: &[u8], message: &[u8]) -> Result<[u8; 64], CryptoError>;

    /// Derive the raw 32-byte Ed25519 public key from a 32-byte signing-key
    /// `seed`, using THIS provider's module (C05). Lets the power-on self-test
    /// and any seed-based derive stay within the active provider — so a FIPS
    /// build derives via aws-lc-rs, never via dalek.
    ///
    /// # Errors
    ///
    /// Returns an error if `seed` is not exactly 32 bytes or the module rejects
    /// the derive.
    fn public_key_from_seed(&self, seed: &[u8]) -> Result<[u8; 32], CryptoError>;

    /// Validate that `public_key` is a structurally valid Ed25519 verifying key
    /// (C06). The default (dalek) provider performs the strict load-time decode
    /// (`VerifyingKey::from_bytes`: on-curve + canonical encoding). The FIPS
    /// provider documents a residual — aws-lc-rs exposes no standalone point
    /// validator, so structural rejection is deferred to verify-time, which the
    /// `c06_aws_lc_rejects_every_key_dalek_rejects_at_load` test proves is
    /// equivalent for *rejection* (a malformed key is inert, never verifies).
    ///
    /// # Errors
    ///
    /// Returns [`CryptoError`] when the bytes are not a usable Ed25519 public
    /// key (length, or — on the dalek provider — off-curve / non-canonical).
    fn validate_ed25519_public_key(&self, public_key: &[u8]) -> Result<(), CryptoError>;

    /// Verify a 64-byte Ed25519 `signature` over `message` against a raw
    /// 32-byte `public_key`. Uses strict verification semantics (rejects
    /// non-canonical encodings / small-order points), matching
    /// `VerifyingKey::verify_strict`.
    ///
    /// # Errors
    ///
    /// Returns an error if the public key or signature is the wrong length, or
    /// if verification fails.
    fn verify_ed25519(
        &self,
        public_key: &[u8],
        message: &[u8],
        signature: &[u8],
    ) -> Result<(), CryptoError>;

    /// Compute HMAC-SHA-256 (RFC 2104 / FIPS 198) of `message` under `key`.
    /// Returns the 32-byte MAC.
    fn hmac_sha256(&self, key: &[u8], message: &[u8]) -> [u8; 32];

    /// Constant-time equality over two byte slices. Returns `false` for
    /// unequal lengths (without an early-exit branch on the contents).
    fn constant_time_eq(&self, a: &[u8], b: &[u8]) -> bool;

    /// Compute the SHA-256 digest of `message`. Returns the 32-byte digest.
    fn sha256(&self, message: &[u8]) -> [u8; 32];
}

/// A trust-anchor Ed25519 public key as raw 32 bytes.
///
/// This is the in-memory keyring value type used across cellos's signed-event
/// and signed-trust-keyset verifiers (S04, ADR-0027). It deliberately replaces
/// the previous `ed25519_dalek::VerifyingKey` leak so that no implementation
/// type crosses a public boundary outside this `crypto` module — a
/// FIPS-validated provider (S05) can be slotted in without changing any keyring
/// signature.
///
/// **Load-time validity.** A `TrustAnchorPublicKey` should be constructed via
/// [`TrustAnchorPublicKey::from_validated_bytes`], which routes the point check
/// through the active provider (C06): on the default build dalek's strict
/// `VerifyingKey::from_bytes` rejects a malformed / non-canonical point at load;
/// under `--features fips` rejection is deferred to verify-time (documented
/// residual — see [`CryptoProvider::validate_ed25519_public_key`]). The raw
/// constructor [`TrustAnchorPublicKey::from_bytes_unchecked`] is available for
/// call sites that have already validated the bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TrustAnchorPublicKey([u8; 32]);

impl TrustAnchorPublicKey {
    /// Wrap raw 32 bytes after validating them as a canonical Ed25519 public
    /// key via the active provider's adapter check.
    ///
    /// # Errors
    ///
    /// Returns [`CryptoError::VerifyFailed`] when the bytes are not a valid
    /// Ed25519 verifying key (non-canonical encoding, small-order point, etc.).
    pub fn from_validated_bytes(bytes: [u8; 32]) -> Result<Self, CryptoError> {
        // C06: route through the active provider so a FIPS-pure build validates
        // without a dalek call. On the default build this is dalek's strict
        // `VerifyingKey::from_bytes`; under fips it is the documented verify-time
        // residual (see CryptoProvider::validate_ed25519_public_key).
        provider().validate_ed25519_public_key(&bytes)?;
        Ok(Self(bytes))
    }

    /// Wrap raw 32 bytes without re-validating. Use only when the bytes have
    /// already been proven to be a valid Ed25519 public key (e.g. derived from
    /// a known-good seed via [`dalek::public_key_from_seed`]).
    pub fn from_bytes_unchecked(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    /// Borrow the raw 32-byte public key for handing to
    /// [`CryptoProvider::verify_ed25519`].
    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }
}

/// Error surface for [`CryptoProvider`] operations.
///
/// Kept distinct from [`crate::error::CellosError`] so the crypto port has no
/// dependency on the broader domain error enum; call sites convert as needed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CryptoError {
    /// A key or signature had the wrong byte length for the algorithm.
    BadLength {
        /// What the bytes were meant to be (e.g. `"ed25519 seed"`).
        what: &'static str,
        /// Expected length in bytes.
        expected: usize,
        /// Actual length supplied.
        got: usize,
    },
    /// Signature verification failed (bad signature, wrong key, non-canonical
    /// encoding under strict verification, etc.).
    VerifyFailed(String),
}

impl core::fmt::Display for CryptoError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            CryptoError::BadLength {
                what,
                expected,
                got,
            } => write!(f, "{what} must be {expected} bytes, got {got}"),
            CryptoError::VerifyFailed(msg) => write!(f, "verify failed: {msg}"),
        }
    }
}

impl std::error::Error for CryptoError {}

impl From<CryptoError> for crate::error::CellosError {
    fn from(e: CryptoError) -> Self {
        crate::error::CellosError::InvalidSpec(format!("crypto provider: {e}"))
    }
}

/// Return the active [`CryptoProvider`] for this build.
///
/// On the default build this is the [`dalek`] adapter (S03). A FIPS build (S05)
/// returns the aws-lc-rs adapter instead. Returned as `&'static dyn` so callers
/// can hold it without ownership concerns; the provider is stateless.
pub fn provider() -> &'static dyn CryptoProvider {
    // S05: under `--features fips` the CMVP-validated aws-lc-fips adapter is the
    // active provider; the default build uses dalek. Wire bytes are identical
    // (Ed25519 / HMAC-SHA-256 / SHA-256 over the S01 canonical payload).
    #[cfg(feature = "fips")]
    {
        &fips::FipsProvider
    }
    #[cfg(all(not(feature = "fips"), feature = "dalek"))]
    {
        &dalek::DalekProvider
    }
}

/// Return the [`ProviderIdentity`] of the active provider.
///
/// Convenience for receipt-stamping call sites that only need the identity.
/// On the default build `fips_mode == false`.
pub fn provider_identity() -> ProviderIdentity {
    provider().identity()
}

/// Power-on self-test (S09, ADR-0027): exercise the active provider's Ed25519
/// sign+verify, HMAC-SHA-256, and SHA-256 before any signing path is trusted.
/// Returns `Err` if a primitive is broken or a tampered message verifies — a
/// hardened/fips startup MUST fail-stop on `Err` rather than sign with an
/// unproven module.
///
/// C05: the public key is derived via the ACTIVE provider's
/// [`public_key_from_seed`](CryptoProvider::public_key_from_seed), so the FIPS
/// build's self-test stays entirely within aws-lc-rs (no dalek call on the fips
/// POST path); the default build derives via dalek, byte-identical to before.
pub fn power_on_self_test() -> Result<(), CryptoError> {
    let p = provider();
    let seed = [0x42u8; 32];
    let msg = b"cellos-crypto-power-on-self-test";
    let sig = p.sign_ed25519(&seed, msg)?;
    let public = p.public_key_from_seed(&seed)?;
    p.verify_ed25519(&public, msg, &sig)?;
    if p.verify_ed25519(&public, b"tampered-post", &sig).is_ok() {
        return Err(CryptoError::VerifyFailed(
            "power-on self-test: a tampered message verified".into(),
        ));
    }
    // HMAC + digest must be deterministic (a non-deterministic primitive is a
    // broken module).
    if p.hmac_sha256(b"post-key", msg) != p.hmac_sha256(b"post-key", msg)
        || p.sha256(msg) != p.sha256(msg)
    {
        return Err(CryptoError::VerifyFailed(
            "power-on self-test: hmac/sha256 not deterministic".into(),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(not(feature = "fips"))]
    #[test]
    fn default_provider_is_non_fips() {
        let id = provider_identity();
        assert!(!id.fips_mode, "default build must not claim FIPS mode");
        assert_eq!(id.name, "dalek");
        assert_eq!(id.module_cert, None);
    }

    /// Under `--features fips` the active provider is the CMVP-validated adapter
    /// and reports `fips_mode = true` (a producer claim — see `fips.rs`).
    #[cfg(feature = "fips")]
    #[test]
    fn fips_feature_selects_fips_provider() {
        let id = provider_identity();
        assert!(id.fips_mode, "fips build must claim FIPS mode");
        assert_eq!(id.name, "aws-lc-fips");
    }

    #[test]
    fn trait_is_object_safe() {
        // If `CryptoProvider` were not object-safe this would not compile.
        let p: &dyn CryptoProvider = provider();
        // Name is provider-specific (dalek vs aws-lc-fips); assert it is present
        // rather than pinning a feature-specific value.
        assert!(!p.identity().name.is_empty());
    }

    #[test]
    fn power_on_self_test_passes_for_active_provider() {
        super::power_on_self_test().expect("POST must pass for a working provider");
    }

    /// C05: the active provider derives a public key from a seed that verifies
    /// its own signatures — exercised feature-agnostically so the FIPS build
    /// proves its derive path stays inside aws-lc-rs.
    #[test]
    fn provider_public_key_from_seed_round_trips() {
        let p = provider();
        let seed = [0x11u8; 32];
        let public = p.public_key_from_seed(&seed).expect("provider derive");
        let msg = b"c05-provider-derive";
        let sig = p.sign_ed25519(&seed, msg).expect("sign");
        p.verify_ed25519(&public, msg, &sig)
            .expect("provider-derived key verifies the provider's own signature");
    }

    #[test]
    fn provider_public_key_from_seed_rejects_bad_length() {
        assert!(
            provider().public_key_from_seed(&[0u8; 31]).is_err(),
            "a 31-byte seed must be rejected"
        );
    }

    /// Property sweep: over many deterministic pseudo-random (seed, message)
    /// pairs, the active provider must sign→verify round-trip, and BOTH a
    /// one-bit message tamper and a one-bit signature tamper must fail. Runs on
    /// the default (dalek) and fips providers alike.
    #[test]
    fn provider_sign_verify_property_sweep() {
        let p = provider();
        for i in 0u32..256 {
            let seed = p.sha256(&i.to_le_bytes());
            let pk = p.public_key_from_seed(&seed).expect("derive");
            let msg = p.sha256(&(i ^ 0xa5a5_a5a5).to_le_bytes());
            let sig = p.sign_ed25519(&seed, &msg).expect("sign");

            p.verify_ed25519(&pk, &msg, &sig)
                .unwrap_or_else(|_| panic!("round-trip must verify (i={i})"));

            // One-bit message tamper.
            let mut m2 = msg;
            m2[i as usize % 32] ^= 1;
            assert!(
                p.verify_ed25519(&pk, &m2, &sig).is_err(),
                "message tamper must fail (i={i})"
            );

            // One-bit signature tamper.
            let mut s2 = sig;
            s2[i as usize % 64] ^= 1;
            assert!(
                p.verify_ed25519(&pk, &msg, &s2).is_err(),
                "signature tamper must fail (i={i})"
            );
        }
    }

    /// C06: on the default (dalek) build, `from_validated_bytes` still rejects a
    /// structurally invalid key at load (strict `VerifyingKey::from_bytes`).
    #[cfg(not(feature = "fips"))]
    #[test]
    fn from_validated_bytes_is_strict_on_default_build() {
        let p = provider();
        let good = p.public_key_from_seed(&[5u8; 32]).unwrap();
        assert!(
            TrustAnchorPublicKey::from_validated_bytes(good).is_ok(),
            "a valid derived key loads"
        );
        let mut rejected = false;
        for i in 0u32..1000 {
            let cand = p.sha256(&i.to_le_bytes());
            if TrustAnchorPublicKey::from_validated_bytes(cand).is_err() {
                rejected = true;
                break;
            }
        }
        assert!(
            rejected,
            "default build must reject some malformed keys at load"
        );
    }

    /// C06 residual (fips build): `from_validated_bytes` enforces length but
    /// defers point-validation to verify-time — a key dalek rejects at load may
    /// still load, then is inert (proven by the fips `c06_aws_lc_rejects...`
    /// test). This pins the documented degradation so it cannot change silently.
    /// Needs both providers present to compare against dalek's strict validator.
    #[cfg(all(feature = "fips", feature = "dalek"))]
    #[test]
    fn from_validated_bytes_defers_to_verify_time_under_fips() {
        let p = provider();
        // Wrong length is still rejected up front.
        assert!(p.validate_ed25519_public_key(&[0u8; 31]).is_err());
        // A key dalek's strict validator rejects still loads under fips (residual).
        let mut found_residual = false;
        for i in 0u32..1000 {
            let cand = p.sha256(&i.to_le_bytes());
            if crate::crypto::dalek::validate_ed25519_public_key(&cand).is_err() {
                assert!(
                    TrustAnchorPublicKey::from_validated_bytes(cand).is_ok(),
                    "fips residual: structurally-invalid key loads (rejection deferred to verify)"
                );
                found_residual = true;
                break;
            }
        }
        assert!(
            found_residual,
            "expected a dalek-rejected candidate to demonstrate the residual"
        );
    }

    /// Doc-style round-trip exercised as a unit test: sign then verify through
    /// the trait object.
    #[test]
    fn ed25519_sign_verify_round_trip_through_trait() {
        let p = provider();
        let seed = [7u8; 32];
        // C05: derive via the active provider's port (no dalek call here).
        let public = p.public_key_from_seed(&seed).expect("derive pub");
        let msg = b"canonical-payload-bytes";
        let sig = p.sign_ed25519(&seed, msg).expect("sign");
        p.verify_ed25519(&public, msg, &sig).expect("verify ok");

        // Tampered message fails.
        assert!(p.verify_ed25519(&public, b"other", &sig).is_err());
    }
}