cellos-core 0.8.0-pre

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! Default (non-FIPS) [`CryptoProvider`] adapter over `ed25519-dalek` + `sha2`
//! (ADR-0027, S03).
//!
//! This wraps the exact primitives the rest of cellos already uses so the
//! provider seam introduces **zero behaviour change**: Ed25519 signing and
//! strict verification mirror `trust_keys::sign_event_ed25519` /
//! `verify_signed_event_envelope` (which call `VerifyingKey::verify_strict`),
//! and `hmac_sha256` / `constant_time_eq` are the same construction moved here
//! from `trust_keys` (their original private copies remain there until the S04
//! call-site refactor deletes them). [`ProviderIdentity::fips_mode`] is
//! `false`.

use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use sha2::{Digest, Sha256};

use super::{CryptoError, CryptoProvider, ProviderIdentity};

/// The default ed25519-dalek + sha2 crypto provider. Stateless; held as a
/// `&'static` by [`super::provider`].
#[derive(Debug, Clone, Copy, Default)]
pub struct DalekProvider;

impl CryptoProvider for DalekProvider {
    fn identity(&self) -> ProviderIdentity {
        ProviderIdentity {
            name: "dalek",
            fips_mode: false,
            module_cert: None,
        }
    }

    fn sign_ed25519(&self, seed: &[u8], message: &[u8]) -> Result<[u8; 64], CryptoError> {
        let seed_arr: [u8; 32] = seed.try_into().map_err(|_| CryptoError::BadLength {
            what: "ed25519 seed",
            expected: 32,
            got: seed.len(),
        })?;
        let signing_key = SigningKey::from_bytes(&seed_arr);
        let signature = signing_key.sign(message);
        Ok(signature.to_bytes())
    }

    fn public_key_from_seed(&self, seed: &[u8]) -> Result<[u8; 32], CryptoError> {
        // C05: delegate to the existing free helper — byte-identical to the
        // prior power-on self-test derive.
        public_key_from_seed(seed)
    }

    fn validate_ed25519_public_key(&self, public_key: &[u8]) -> Result<(), CryptoError> {
        // C06: strict load-time decode (on-curve + canonical), unchanged.
        let arr: [u8; 32] = public_key.try_into().map_err(|_| CryptoError::BadLength {
            what: "ed25519 public key",
            expected: 32,
            got: public_key.len(),
        })?;
        validate_ed25519_public_key(&arr)
    }

    fn verify_ed25519(
        &self,
        public_key: &[u8],
        message: &[u8],
        signature: &[u8],
    ) -> Result<(), CryptoError> {
        let pk_arr: [u8; 32] = public_key.try_into().map_err(|_| CryptoError::BadLength {
            what: "ed25519 public key",
            expected: 32,
            got: public_key.len(),
        })?;
        let sig_arr: [u8; 64] = signature.try_into().map_err(|_| CryptoError::BadLength {
            what: "ed25519 signature",
            expected: 64,
            got: signature.len(),
        })?;
        let verifying_key = VerifyingKey::from_bytes(&pk_arr)
            .map_err(|e| CryptoError::VerifyFailed(format!("invalid ed25519 public key: {e}")))?;
        let sig = Signature::from_bytes(&sig_arr);
        // Strict verification — same call as `verify_signed_event_envelope`.
        verifying_key
            .verify_strict(message, &sig)
            .map_err(|e| CryptoError::VerifyFailed(format!("ed25519 verify failed: {e}")))
    }

    fn hmac_sha256(&self, key: &[u8], message: &[u8]) -> [u8; 32] {
        hmac_sha256(key, message)
    }

    fn constant_time_eq(&self, a: &[u8], b: &[u8]) -> bool {
        constant_time_eq(a, b)
    }

    fn sha256(&self, message: &[u8]) -> [u8; 32] {
        let mut hasher = Sha256::new();
        hasher.update(message);
        let digest = hasher.finalize();
        let mut out = [0u8; 32];
        out.copy_from_slice(&digest);
        out
    }
}

/// Derive the raw 32-byte Ed25519 public key from a 32-byte seed.
///
/// Helper for tests / call sites that hold only a seed and need the matching
/// verifying key without importing dalek types. Returns the public key bytes.
///
/// # Errors
///
/// Returns [`CryptoError::BadLength`] if `seed` is not exactly 32 bytes.
pub fn public_key_from_seed(seed: &[u8]) -> Result<[u8; 32], CryptoError> {
    let seed_arr: [u8; 32] = seed.try_into().map_err(|_| CryptoError::BadLength {
        what: "ed25519 seed",
        expected: 32,
        got: seed.len(),
    })?;
    let signing_key = SigningKey::from_bytes(&seed_arr);
    Ok(signing_key.verifying_key().to_bytes())
}

/// Validate that `bytes` is a canonical Ed25519 public key.
///
/// Preserves the load-time check that `VerifyingKey::from_bytes` performed in
/// `trust_keys::parse_trust_verify_keys` (S04): a non-canonical encoding,
/// small-order point, or otherwise malformed key is rejected here rather than
/// being deferred to verify time. Keeps the dalek type contained in this
/// module — callers receive only `Result<(), CryptoError>`.
///
/// # Errors
///
/// Returns [`CryptoError::VerifyFailed`] when the bytes are not a valid
/// Ed25519 verifying key.
pub fn validate_ed25519_public_key(bytes: &[u8; 32]) -> Result<(), CryptoError> {
    VerifyingKey::from_bytes(bytes)
        .map(|_| ())
        .map_err(|e| CryptoError::VerifyFailed(format!("invalid ed25519 public key: {e}")))
}

/// HMAC-SHA256 (RFC 2104 / FIPS 198). Returns the 32-byte MAC.
///
/// Moved verbatim from `trust_keys::hmac_sha256` so the provider seam is
/// byte-for-byte compatible with the legacy inline construction.
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
    const BLOCK: usize = 64;
    let mut block_key = [0u8; BLOCK];
    if key.len() > BLOCK {
        let mut hasher = Sha256::new();
        hasher.update(key);
        let digest = hasher.finalize();
        block_key[..32].copy_from_slice(&digest);
    } else {
        block_key[..key.len()].copy_from_slice(key);
    }

    let mut ipad = [0u8; BLOCK];
    let mut opad = [0u8; BLOCK];
    for i in 0..BLOCK {
        ipad[i] = block_key[i] ^ 0x36;
        opad[i] = block_key[i] ^ 0x5c;
    }

    let mut inner = Sha256::new();
    inner.update(ipad);
    inner.update(message);
    let inner_digest = inner.finalize();

    let mut outer = Sha256::new();
    outer.update(opad);
    outer.update(inner_digest);
    let mac = outer.finalize();

    let mut out = [0u8; 32];
    out.copy_from_slice(&mac);
    out
}

/// Constant-time equality over byte slices. Returns `false` for unequal
/// lengths. Moved verbatim from `trust_keys::constant_time_eq`.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff: u8 = 0;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::trust_keys::{
        sign_event_ed25519, verify_signed_event_envelope, SignedEventEnvelopeV1,
    };
    use crate::types::CloudEventV1;
    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    use base64::Engine as _;
    use std::collections::HashMap;

    fn sample_event() -> CloudEventV1 {
        CloudEventV1 {
            specversion: "1.0".into(),
            id: "ev-parity-1".into(),
            source: "urn:cellos:test".into(),
            ty: "dev.cellos.events.cell.lifecycle.v1.started".into(),
            datacontenttype: Some("application/json".into()),
            data: Some(serde_json::json!({"cellId": "test-cell-1"})),
            time: Some("2026-05-06T12:00:00Z".into()),
            traceparent: None,
            cex: None,
        }
    }

    #[test]
    fn identity_is_non_fips_dalek() {
        let id = DalekProvider.identity();
        assert_eq!(id.name, "dalek");
        assert!(!id.fips_mode);
        assert_eq!(id.module_cert, None);
    }

    #[test]
    fn ed25519_sign_matches_legacy_sign_event() {
        // The adapter signs the SAME canonical payload bytes that
        // sign_event_ed25519 produces, with the SAME key, and must yield the
        // SAME 64-byte signature.
        let seed = [31u8; 32];
        let event = sample_event();

        let legacy_envelope: SignedEventEnvelopeV1 =
            sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("legacy sign");
        let legacy_sig = legacy_envelope.signature.clone();

        let payload = crate::canonical::canonical_payload(&event).expect("payload");
        let adapter_sig = DalekProvider
            .sign_ed25519(&seed, &payload)
            .expect("adapter sign");
        let adapter_sig_b64 = URL_SAFE_NO_PAD.encode(adapter_sig);

        assert_eq!(
            adapter_sig_b64, legacy_sig,
            "adapter signature must equal legacy sign_event_ed25519 for fixed input"
        );
    }

    #[test]
    fn ed25519_verify_parity_with_envelope_verifier() {
        // Sign via the adapter, then verify the resulting envelope through the
        // legacy verifier — and vice versa — to prove cross-compatibility.
        let seed = [42u8; 32];
        let event = sample_event();
        let public = public_key_from_seed(&seed).unwrap();

        let payload = crate::canonical::canonical_payload(&event).unwrap();
        let sig = DalekProvider.sign_ed25519(&seed, &payload).unwrap();

        // Adapter verifies its own signature.
        DalekProvider
            .verify_ed25519(&public, &payload, &sig)
            .expect("adapter verify ok");

        // Build an envelope from the adapter sig and verify with the legacy
        // verifier against the matching VerifyingKey.
        let envelope = SignedEventEnvelopeV1 {
            event: event.clone(),
            signer_kid: "k".into(),
            algorithm: "ed25519".into(),
            signature: URL_SAFE_NO_PAD.encode(sig),
            not_before: None,
            not_after: None,
        };
        let mut keys = HashMap::new();
        keys.insert(
            "k".to_string(),
            crate::crypto::TrustAnchorPublicKey::from_validated_bytes(public).unwrap(),
        );
        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
        verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("legacy verify ok");
    }

    #[test]
    fn ed25519_verify_rejects_tampered_signature() {
        let seed = [9u8; 32];
        let public = public_key_from_seed(&seed).unwrap();
        let msg = b"hello";
        let mut sig = DalekProvider.sign_ed25519(&seed, msg).unwrap();
        sig[0] ^= 0xff;
        assert!(DalekProvider.verify_ed25519(&public, msg, &sig).is_err());
    }

    #[test]
    fn ed25519_verify_rejects_wrong_length_signature() {
        let seed = [9u8; 32];
        let public = public_key_from_seed(&seed).unwrap();
        let short = [0u8; 10];
        let err = DalekProvider
            .verify_ed25519(&public, b"hello", &short)
            .unwrap_err();
        assert!(matches!(
            err,
            CryptoError::BadLength {
                what: "ed25519 signature",
                expected: 64,
                got: 10
            }
        ));
    }

    #[test]
    fn sign_rejects_wrong_length_seed() {
        let err = DalekProvider.sign_ed25519(&[0u8; 5], b"x").unwrap_err();
        assert!(matches!(
            err,
            CryptoError::BadLength {
                what: "ed25519 seed",
                expected: 32,
                got: 5
            }
        ));
    }

    #[test]
    fn hmac_sha256_parity_with_known_construction() {
        // Parity: adapter HMAC equals the moved-verbatim free function (which is
        // the same construction trust_keys uses for sign_event_hmac_sha256).
        let key = b"super-secret-shared-symmetric-key";
        let msg = b"some canonical payload bytes";
        let adapter = DalekProvider.hmac_sha256(key, msg);
        let direct = hmac_sha256(key, msg);
        assert_eq!(adapter, direct);

        // Long-key path (> block size) exercises the key-hashing branch.
        let long_key = vec![0xABu8; 100];
        assert_eq!(
            DalekProvider.hmac_sha256(&long_key, msg),
            hmac_sha256(&long_key, msg)
        );
    }

    #[test]
    fn hmac_sha256_matches_sign_event_hmac() {
        // End-to-end parity: the MAC the adapter computes over the canonical
        // payload equals the base64url MAC sign_event_hmac_sha256 emits.
        let key = b"shared-key-material";
        let event = sample_event();
        let envelope =
            crate::trust_keys::sign_event_hmac_sha256(&event, "kid", key).expect("hmac sign");
        let payload = crate::canonical::canonical_payload(&event).unwrap();
        let adapter_mac = DalekProvider.hmac_sha256(key, &payload);
        assert_eq!(URL_SAFE_NO_PAD.encode(adapter_mac), envelope.signature);
    }

    #[test]
    fn constant_time_eq_parity() {
        assert!(DalekProvider.constant_time_eq(b"abc", b"abc"));
        assert!(!DalekProvider.constant_time_eq(b"abc", b"abd"));
        assert!(!DalekProvider.constant_time_eq(b"abc", b"ab"));
        assert!(DalekProvider.constant_time_eq(b"", b""));
    }

    #[test]
    fn sha256_matches_direct_digest() {
        let msg = b"content to hash";
        let mut hasher = Sha256::new();
        hasher.update(msg);
        let expected: [u8; 32] = hasher.finalize().into();
        assert_eq!(DalekProvider.sha256(msg), expected);
    }
}