polyc-crypto 2026.8.3

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
//! Canonical signing for edge-identity attribution envelopes.
//!
//! An [`AssertedAttribution`] is the self-contained, signed envelope an edge
//! attaches to a turn to prove which edge asserts the `caller`/`participants`
//! it carries, and that the assertion is fresh and unmodified (see
//! `crates/proto/proto/agent_service.proto`). It commits to its *canonical
//! bytes*: this module's `EDGE_IDENTITY_DOMAIN_PREFIX` followed by the buffa encoding of
//! the message with its `signature_hex` field cleared. Both the signer and
//! the verifier clear the field before encoding, so the signature covers
//! every other field (`edge_id`, `conversation_id`, `nonce`,
//! `issued_unix_ms`, `caller`, `participants`, `exec_id`, `content_hash`,
//! `namespace`) but
//! not itself — mirrors the [`crate::toolcall`] signing pattern. `canonical_bytes`
//! clones the whole message and clears only `signature_hex`, so any field
//! added to [`AssertedAttribution`] is automatically covered without further
//! change here.
//!
//! The domain prefix (mirroring `session`'s own `SESSION_DOMAIN_PREFIX`)
//! separates this artifact kind from every other proto-message-shaped
//! signature this crate mints under the same [`crate::NAMESPACE`] — a
//! signature minted over the raw `AssertedAttribution` encoding (no prefix)
//! can never be replayed as a valid envelope signature, and vice versa.
//!
//! Use [`sign_edge_assertion_into`] to mint a signature and fill the
//! message's field in place, or [`sign_edge_assertion`] for the signature
//! alone; [`verify_edge_assertion`] checks provenance. The verifier never
//! panics — a malformed `signature_hex` surfaces as `false`.

use buffa::Message as _;
use polyc_proto::proto::polychrome::agent::v1::AssertedAttribution;
use sha2::{Digest, Sha256};

use crate::{Signer, verify};

/// Domain-separation prefix prepended to an [`AssertedAttribution`]'s signed
/// bytes (trailing NUL, exactly like `session`'s own `SESSION_DOMAIN_PREFIX`,
/// so no legal buffa encoding can extend it into a prefix collision).
///
/// The exact bytes are load-bearing: changing them invalidates every
/// currently-live edge-identity envelope signature.
///
/// Crate-visible so [`crate::approval_assertion`]'s tests can mint a
/// cross-domain forgery against the *real* prefix rather than a copy of it —
/// a copy would still pass if the two prefixes ever converged.
pub(crate) const EDGE_IDENTITY_DOMAIN_PREFIX: &[u8] = b"polychrome.edge-identity.v1\0";

/// Canonical bytes for an [`AssertedAttribution`]: [`EDGE_IDENTITY_DOMAIN_PREFIX`]
/// followed by the encoding with `signature_hex` cleared, so the signature
/// commits to everything *except* itself.
fn canonical_bytes(a: &AssertedAttribution) -> Vec<u8> {
    let mut canonical = a.clone();
    canonical.signature_hex.clear();
    let encoded = canonical.encode_to_vec();
    let mut bytes = Vec::with_capacity(EDGE_IDENTITY_DOMAIN_PREFIX.len() + encoded.len());
    bytes.extend_from_slice(EDGE_IDENTITY_DOMAIN_PREFIX);
    bytes.extend_from_slice(&encoded);
    bytes
}

/// Sign the canonical bytes of `a`; returns the lowercase-hex signature
/// suitable for [`AssertedAttribution::signature_hex`].
#[must_use]
pub fn sign_edge_assertion(signer: &Signer, a: &AssertedAttribution) -> String {
    crate::hex::lower(&signer.sign(&canonical_bytes(a)))
}

/// Sign `a` and store the hex signature in its `signature_hex` field in
/// place.
pub fn sign_edge_assertion_into(signer: &Signer, a: &mut AssertedAttribution) {
    a.signature_hex = sign_edge_assertion(signer, a);
}

/// The sha256-hex of `messages_encoded`.
///
/// The caller signs this over the encoding of `AgentStart.messages` into
/// [`AssertedAttribution::content_hash`], and the control plane recomputes it
/// at verification time, binding the envelope to this turn's content.
#[must_use]
pub fn content_hash_hex(messages_encoded: &[u8]) -> String {
    crate::hex::lower(&Sha256::digest(messages_encoded))
}

/// Verify the provenance signature carried in `a.signature_hex` against an
/// encoded `public_key`.
///
/// Returns `false` on any hex-decode failure or signature mismatch — never
/// panics.
#[must_use]
pub fn verify_edge_assertion(public_key: &[u8], a: &AssertedAttribution) -> bool {
    let Some(sig) = crate::hex::decode(&a.signature_hex) else {
        return false;
    };
    verify(public_key, &canonical_bytes(a), &sig)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;

    use super::*;

    fn sample_caller() -> ExternalIdentity {
        ExternalIdentity {
            provider: "slack".to_owned(),
            scope: "team-1".to_owned(),
            external_id: "U123".to_owned(),
            display_name: "Ada".to_owned(),
            ..Default::default()
        }
    }

    fn sample_assertion() -> AssertedAttribution {
        AssertedAttribution {
            edge_id: "slack".to_owned(),
            conversation_id: "slack:team-1:general".to_owned(),
            nonce: "nonce-1".to_owned(),
            issued_unix_ms: 1_700_000_000_000,
            caller: buffa::MessageField::some(sample_caller()),
            exec_id: "exec-1".to_owned(),
            content_hash: content_hash_hex(b"messages-bytes"),
            ..Default::default()
        }
    }

    #[test]
    fn round_trips() {
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        sign_edge_assertion_into(&signer, &mut a);
        assert!(!a.signature_hex.is_empty());
        assert!(verify_edge_assertion(&pk, &a));
    }

    #[test]
    fn content_hash_hex_is_deterministic_and_content_sensitive() {
        let a = content_hash_hex(b"hello");
        let b = content_hash_hex(b"hello");
        let c = content_hash_hex(b"hellp");
        assert_eq!(a, b);
        assert_ne!(a, c);
        assert_eq!(a.len(), 64, "sha256 hex is 64 lowercase hex chars");
        assert!(
            a.chars()
                .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase())
        );
    }

    #[test]
    fn tampered_exec_id_fails() {
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        sign_edge_assertion_into(&signer, &mut a);
        a.exec_id = "exec-2".to_owned();
        assert!(!verify_edge_assertion(&pk, &a));
    }

    /// INV-N4: the tenancy namespace claim is covered by the envelope
    /// signature. [`canonical_bytes`] encodes the whole message, so this holds
    /// by construction — the test pins it, because the claim decides
    /// authorization and a field that silently left the signed set would make
    /// the check prove nothing.
    #[test]
    fn a_tampered_namespace_claim_fails_verification() {
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        a.namespace = "slack".to_owned();
        sign_edge_assertion_into(&signer, &mut a);
        assert!(verify_edge_assertion(&pk, &a));
        a.namespace = "mail".to_owned();
        assert!(!verify_edge_assertion(&pk, &a));
    }

    #[test]
    fn tampered_content_hash_fails() {
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        sign_edge_assertion_into(&signer, &mut a);
        a.content_hash = content_hash_hex(b"different-messages-bytes");
        assert!(!verify_edge_assertion(&pk, &a));
    }

    #[test]
    fn tampered_edge_id_fails() {
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        sign_edge_assertion_into(&signer, &mut a);
        a.edge_id = "telegram".to_owned();
        assert!(!verify_edge_assertion(&pk, &a));
    }

    #[test]
    fn tampered_caller_fails() {
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        sign_edge_assertion_into(&signer, &mut a);
        a.caller = buffa::MessageField::some(ExternalIdentity {
            external_id: "U999".to_owned(),
            ..sample_caller()
        });
        assert!(!verify_edge_assertion(&pk, &a));
    }

    #[test]
    fn wrong_key_fails() {
        let signer = Signer::from_seed(31);
        let other = Signer::from_seed(32);
        let mut a = sample_assertion();
        sign_edge_assertion_into(&signer, &mut a);
        assert!(!verify_edge_assertion(&other.public_key_bytes(), &a));
    }

    #[test]
    fn unsigned_fails() {
        let signer = Signer::from_seed(31);
        let a = sample_assertion();
        assert!(!verify_edge_assertion(&signer.public_key_bytes(), &a));
    }

    #[test]
    fn garbage_signature_hex_returns_false_not_panic() {
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        a.signature_hex = "not-hex!".to_owned();
        assert!(!verify_edge_assertion(&pk, &a));
        a.signature_hex = "abc".to_owned();
        assert!(!verify_edge_assertion(&pk, &a));
    }

    #[test]
    fn domain_confusion_is_rejected() {
        // A signature the SAME signer minted over the RAW `AssertedAttribution`
        // encoding (no `EDGE_IDENTITY_DOMAIN_PREFIX`) must never verify as an
        // edge-identity envelope signature — proves the domain-separation
        // prefix actually separates this artifact kind, not just that
        // signatures verify at all.
        let signer = Signer::from_seed(31);
        let pk = signer.public_key_bytes();
        let mut a = sample_assertion();
        a.signature_hex.clear();
        let raw_encoded = a.encode_to_vec();
        let forged_sig = signer.sign(&raw_encoded);
        a.signature_hex = crate::hex::lower(&forged_sig);

        assert!(
            !verify_edge_assertion(&pk, &a),
            "a signature minted over the undomain-prefixed encoding must not verify"
        );
    }
}