klieo-ops 3.3.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! Pluggable signing for handoff envelopes.
//!
//! `SignerProvider` abstracts the act of producing an Ed25519 signature
//! over a canonical-JSON payload. The default `SoftwareSigner` wraps
//! `ed25519_dalek::SigningKey` directly. HSM- / KMS-backed adapters
//! implement the trait against their respective vendor SDK without
//! modifying klieo-ops.
//!
//! Closes spec ยง 5.4 OQ-3.

use async_trait::async_trait;
use ed25519_dalek::{Signer as EdSigner, SigningKey, Verifier as EdVerifier, VerifyingKey};
use klieo_provenance::{BundleSigner, ProvenanceError, SignatureBytes};
use rand::rngs::OsRng;
use thiserror::Error;

/// Algorithm tag emitted by [`SoftwareSigner`]'s [`BundleSigner`] impl and
/// matched against the verifier allowlist at verify time.
const ED25519_ALGORITHM: &str = "ed25519";

/// Number of leading hex chars of the verifying key used to form a stable,
/// human-readable `key_id` for resolver lookup.
const KEY_ID_PREFIX_LEN: usize = 8;

/// Output of a sign call: the raw 64-byte Ed25519 signature plus the
/// 32-byte verifying key that should be embedded in the envelope so the
/// receiver can resolve it against its trust registry.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct SignedPayload {
    /// 64-byte Ed25519 signature.
    pub signature: [u8; 64],
    /// 32-byte Ed25519 verifying key.
    pub verifying_key: [u8; 32],
}

impl SignedPayload {
    /// Construct a `SignedPayload`. Prefer this over struct-literal syntax
    /// โ€” `#[non_exhaustive]` blocks cross-crate struct literals.
    #[must_use]
    pub fn new(signature: [u8; 64], verifying_key: [u8; 32]) -> Self {
        Self {
            signature,
            verifying_key,
        }
    }
}

/// Errors raised by `SignerProvider` impls.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SignerError {
    /// Underlying HSM / KMS rejected the request.
    #[error("signer backend: {0}")]
    Backend(String),
    /// Other internal error.
    #[error("internal: {0}")]
    Internal(String),
}

/// Pluggable Ed25519 signer.
#[async_trait]
pub trait SignerProvider: Send + Sync {
    /// Sign `payload` and return both the signature and the verifying key.
    async fn sign_handoff(&self, payload: &[u8]) -> Result<SignedPayload, SignerError>;
}

/// Default impl backed by an in-process `ed25519_dalek::SigningKey`.
///
/// Suitable for dev and non-regulated deployments. Production tenants
/// requiring HSM-backed signing implement `SignerProvider` against
/// their vendor SDK.
pub struct SoftwareSigner {
    key: SigningKey,
}

impl SoftwareSigner {
    /// Build from an existing `SigningKey`.
    #[must_use]
    pub fn new(key: SigningKey) -> Self {
        Self { key }
    }

    /// Build deterministically from 32 bytes โ€” convenience for tests.
    #[must_use]
    pub fn from_bytes(seed: [u8; 32]) -> Self {
        Self::new(SigningKey::from_bytes(&seed))
    }

    /// Generate a fresh keypair off the OS CSPRNG.
    ///
    /// **Dev / test only.** The private key lives in process memory and is
    /// lost on exit, so signatures are not reproducible and the key cannot
    /// be rotated, escrowed, or HSM-protected. For production use
    /// [`Self::from_bytes`] / [`Self::new`] with managed key material, or
    /// implement [`SignerProvider`] against an HSM / KMS adapter.
    #[must_use]
    pub fn generate() -> Self {
        Self::new(SigningKey::generate(&mut OsRng))
    }

    /// Lowercase hex of the 32-byte Ed25519 verifying (public) key โ€” always
    /// 64 hex chars. Informational only at verify time (see the `BundleSigner`
    /// impl's CWE-347 note); the trusted key is resolved via `KeyResolver`.
    #[must_use]
    pub fn public_key_hex(&self) -> String {
        hex::encode(self.key.verifying_key().to_bytes())
    }

    /// Stable key identifier derived from the verifying-key fingerprint.
    /// Used as [`SignatureBytes::key_id`] so a verifier's `KeyResolver`
    /// can resolve the trusted public key.
    #[must_use]
    pub fn key_id(&self) -> String {
        let pub_hex = self.public_key_hex();
        format!("klieo-ops-{}", &pub_hex[..KEY_ID_PREFIX_LEN])
    }

    /// Verify that `signature_hex` is a valid Ed25519 signature over
    /// `payload` under this signer's keypair.
    ///
    /// Returns `false` on malformed hex, wrong-length signature bytes, or
    /// a cryptographic mismatch โ€” never panics. This is a convenience for
    /// callers that hold the signer; bundle verification proper resolves
    /// the trusted key via a `KeyResolver` (see [`crate::SignerProvider`]
    /// docs and `klieo_provenance::EvidenceBundle::verify`).
    #[must_use]
    pub fn verify(&self, payload: &[u8], signature_hex: &str) -> bool {
        let Ok(sig_bytes) = hex::decode(signature_hex) else {
            return false;
        };
        let Ok(sig) = ed25519_dalek::Signature::from_slice(&sig_bytes) else {
            return false;
        };
        self.verifying_key().verify(payload, &sig).is_ok()
    }

    fn verifying_key(&self) -> VerifyingKey {
        self.key.verifying_key()
    }
}

// CWE-347 (improper verification of cryptographic signature) defence:
// `SignatureBytes::public_key_hex` produced here is INFORMATIONAL ONLY.
// A verifier MUST resolve the trusted key from a `KeyResolver` keyed on
// `key_id` and ignore the inline public key โ€” a malicious signer can ship
// any public key alongside a valid signature against it (the JWT
// `alg=none` analogue). See `klieo_provenance::SignatureBytes` docs.
#[async_trait]
impl BundleSigner for SoftwareSigner {
    async fn sign(&self, payload: &[u8]) -> Result<SignatureBytes, ProvenanceError> {
        let signature = self.key.sign(payload);
        Ok(SignatureBytes {
            algorithm: ED25519_ALGORITHM.to_string(),
            signature_hex: hex::encode(signature.to_bytes()),
            public_key_hex: self.public_key_hex(),
            key_id: Some(self.key_id()),
        })
    }
}

#[async_trait]
impl SignerProvider for SoftwareSigner {
    async fn sign_handoff(&self, payload: &[u8]) -> Result<SignedPayload, SignerError> {
        let sig = self.key.sign(payload);
        Ok(SignedPayload {
            signature: sig.to_bytes(),
            verifying_key: self.key.verifying_key().to_bytes(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
    use klieo_provenance::BundleSigner;

    #[tokio::test]
    async fn software_signer_produces_verifiable_signature() {
        let signer = SoftwareSigner::from_bytes([7u8; 32]);
        let payload = b"hello";
        let signed = signer.sign_handoff(payload).await.expect("sign");
        let vk = VerifyingKey::from_bytes(&signed.verifying_key).expect("vk");
        let sig = Signature::from_bytes(&signed.signature);
        vk.verify(payload, &sig).expect("verify");
    }

    #[tokio::test]
    async fn bundle_signer_emits_ed25519_signature_bytes() {
        let signer = SoftwareSigner::from_bytes([7u8; 32]);
        let payload = b"evidence-bundle-payload";
        let sig = BundleSigner::sign(&signer, payload).await.expect("sign");
        assert_eq!(sig.algorithm, "ed25519");
        assert_eq!(sig.public_key_hex, signer.public_key_hex());

        let key_id = signer.key_id();
        assert_eq!(
            sig.key_id.as_deref(),
            Some(key_id.as_str()),
            "bundle key_id must match the signer's stable key_id"
        );
        const KEY_ID_PREFIX: &str = "klieo-ops-";
        assert!(
            key_id.starts_with(KEY_ID_PREFIX),
            "key_id must use the documented prefix"
        );
        assert_eq!(
            key_id.len(),
            KEY_ID_PREFIX.len() + KEY_ID_PREFIX_LEN,
            "key_id is the prefix plus {KEY_ID_PREFIX_LEN} hex chars of the verifying key"
        );
    }

    #[tokio::test]
    async fn bundle_signature_round_trips_against_public_key() {
        let signer = SoftwareSigner::from_bytes([7u8; 32]);
        let payload = b"evidence-bundle-payload";
        let sig = BundleSigner::sign(&signer, payload).await.expect("sign");
        assert!(
            signer.verify(payload, &sig.signature_hex),
            "real payload must verify"
        );
        assert!(
            !signer.verify(b"tampered-payload", &sig.signature_hex),
            "tampered payload must be rejected"
        );
        assert!(
            !signer.verify(payload, "deadbeef"),
            "malformed signature hex must be rejected"
        );
    }

    #[tokio::test]
    async fn generated_signer_round_trips() {
        let signer = SoftwareSigner::generate();
        let payload = b"fresh-keypair-payload";
        let sig = BundleSigner::sign(&signer, payload).await.expect("sign");
        assert!(signer.verify(payload, &sig.signature_hex));
        assert!(!signer.verify(b"other", &sig.signature_hex));
    }
}