polyc-crypto 2026.8.3

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
//! The two primitives every signed canonical in this crate is built from: the
//! serializer that produces the exact bytes a signature covers, and the
//! envelope that appends the provenance fields afterwards.
//!
//! Extracted from [`crate::approval`] (`#1842`) once [`crate::mandate`],
//! `crate::grant`, and [`crate::question`] adopted the same pattern
//! (`#1845`), so one declaration governs every such module rather than a copy
//! per module drifting apart. `crate::grant` itself went with the approvals
//! passkey (#2407 slice 5); the pattern it helped establish did not.
//!
//! See `docs/decisions/0009-field-declaration-order-is-the-signed-contract.md` for the
//! decision this module implements and the alternatives it rejects.
//!
//! Both items are `pub(crate)` deliberately. Every signed canonical in the tree
//! is built here, so nothing outside this crate needs them. `crates/passkey`
//! used to be the one exception — `derive.rs`'s
//! `persona_credential_binding_canonical` built its own canonical and could not
//! call these, satisfying the same invariant through a `BTreeMap` instead. That
//! module went with the approvals passkey (2026-08-10). The remaining passkey
//! challenge helper only hashes a canonical that its caller already built.
//!
//! Should a second crate ever build one again, widening these is still not the
//! fix it looks like. A `passkey → crypto` edge would invert the foundation
//! dependency rule; sharing one declaration needs this module moved below both,
//! which is the deferred sealed-`CanonicalBody` work in ADR 0009 — not a `pub`
//! keyword here.

use serde::Serialize;

use crate::Signer;

/// Serialize a canonical body to the exact bytes a signature covers.
///
/// Every signed canonical in this crate routes through here, and every one of
/// them is a `#[derive(Serialize)]` struct rather than a [`serde_json::Value`]
/// (`#1842`, `#1845`). The distinction is the whole point: a `Value`'s object
/// is a `BTreeMap` (keys sorted) by default and an `IndexMap` (insertion
/// order) the moment anything in the build graph turns on
/// `serde_json/preserve_order`, so serializing one makes the signed bytes —
/// and therefore the signature — a property of the *dependency graph of
/// whatever binary happens to be signing or verifying*. A struct has no such
/// map: `serialize_struct` writes fields in declaration order, always. The key
/// order of every canonical is therefore fixed by the source, and a verifier
/// with a leaner dependency set (a standalone query plane, an audit tool, a
/// conformance harness) computes the same bytes this control plane signs.
///
/// The declaration order of each struct's fields IS the signed contract.
/// Reordering a field is a signed-payload change, exactly as renaming one is.
///
/// # Panics
///
/// Never for any canonical in this crate, and the reason is worth stating
/// precisely rather than as "never in practice", because two different
/// properties carry it.
///
/// By *field type*: the canonicals are plain structs of strings, integers,
/// booleans, and sequences of those, and `serde_json` fails only on a non-string
/// map key or a non-finite float, neither of which any canonical can hold.
///
/// By *shape*, which the type system does not enforce: serializing an
/// [`Envelope<T>`] flattens `T`, and flatten errors with "can only flatten
/// structs and maps" if `T` does not serialize as one. So
/// `canonical_bytes(&Envelope::<u64> { .. })` would panic here. Every body in
/// this crate is a struct; a caller introducing a non-struct body is the one way
/// to reach this panic, which is why [`Envelope`] documents the requirement.
pub(crate) fn canonical_bytes<T: Serialize>(body: &T) -> Vec<u8> {
    serde_json::to_vec(body).expect("a canonical struct of scalars always serializes")
}

/// A canonical body plus the two provenance fields appended after signing.
///
/// `signed_by` and `signature_hex` are NOT covered by the signature (it cannot
/// cover itself), so they live here rather than in any canonical struct.
/// Flattening the body keeps the persisted payload's field order equal to the
/// canonical's followed by the two provenance fields — the order the
/// build-then-insert construction produced, now fixed by this declaration
/// rather than by a map (`#1842`). Flatten forwards each of the body's fields as
/// a map entry in declaration order, writing straight to the JSON output, so it
/// never builds a [`serde_json::Value`] and never consults a map type.
///
/// `T` carries two requirements the bound cannot express, both of which every
/// canonical in this crate meets:
///
/// - **`T` must serialize as a struct or a map.** Flatten rejects anything else
///   at runtime, which [`canonical_bytes`] turns into a panic on the signing
///   path.
/// - **`T` must not have a field named `signed_by` or `signature_hex`.** Flatten
///   does not deduplicate, so such a field would emit a duplicate JSON key, and
///   which of the two a reader keeps depends on the map type it parses with —
///   reintroducing the build dependence one level up.
#[derive(Serialize)]
pub(crate) struct Envelope<T> {
    /// The canonical body the signature covers.
    #[serde(flatten)]
    body: T,
    /// Lowercase-hex encoded public key of the signer.
    signed_by: String,
    /// Lowercase-hex encoded ed25519 signature over the canonical body.
    signature_hex: String,
}

impl<T: Serialize> Envelope<T> {
    /// Assemble the persisted payload bytes for `body` signed by `signer`,
    /// returning `(full_payload_bytes, signature_bytes, public_key_bytes)` —
    /// the shape every payload builder in this crate returns.
    pub(crate) fn seal(body: T, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
        let signature = signer.sign(&canonical_bytes(&body));
        let pk = signer.public_key_bytes();
        let full = Self {
            body,
            signed_by: crate::hex::lower(&pk),
            signature_hex: crate::hex::lower(&signature),
        };
        (canonical_bytes(&full), signature, pk)
    }
}

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

    /// The environment variable a build selection sets to declare that it
    /// resolved `serde_json/preserve_order` **off**, so the test below can hold
    /// it to that. `scripts/check_canonical_bytes.py` checks that `justfile` and
    /// `cloudbuild/ci.yaml` both still set it.
    const EXPECT_SORTED_JSON: &str = "POLYC_EXPECT_SORTED_JSON";

    /// The frozen canonical literals only prove the bytes are independent of the
    /// build graph if something actually builds them BOTH ways, and a
    /// `--workspace` run always resolves `serde_json/preserve_order` on. So
    /// `cargo nextest run -p polyc-crypto` is run separately, in `just test-all`
    /// and in `cloudbuild/ci.yaml`, to get the feature off.
    ///
    /// That selection being lean is a property of `polyc-crypto`'s *transitive*
    /// graph, though: nothing in any `Cargo.toml` in this workspace names
    /// `preserve_order`, it arrives through a dependency. So the day someone adds
    /// a dep here that pulls it in, that second run silently becomes a duplicate
    /// of the workspace one, all the frozen literals still pass, and the
    /// build-independence proof is gone with no signal at all.
    ///
    /// This test is that signal. The lean invocations set
    /// [`EXPECT_SORTED_JSON`]; when it is set, a `serde_json` map MUST be sorted
    /// (`BTreeMap`), and if the feature has crept in, this fails by name instead
    /// of degrading quietly. `scripts/check_canonical_bytes.py` checks that both
    /// invocations still set it, so the pair cannot rot either.
    #[test]
    fn the_lean_selection_still_resolves_preserve_order_off() {
        if std::env::var(EXPECT_SORTED_JSON).as_deref() != Ok("1") {
            // A workspace build resolves the feature on; it is the lean run's job
            // to assert, and it announces itself with the variable.
            return;
        }
        // Inserted out of order on purpose: sorted output proves `BTreeMap`.
        let mut map = serde_json::Map::new();
        map.insert("b".to_owned(), serde_json::Value::from(1));
        map.insert("a".to_owned(), serde_json::Value::from(2));
        assert_eq!(
            serde_json::Value::Object(map).to_string(),
            r#"{"a":2,"b":1}"#,
            "{EXPECT_SORTED_JSON} was set, so this selection is supposed to resolve \
             serde_json/preserve_order OFF — but a map kept insertion order, so the \
             feature is now ON here. Some dependency of polyc-crypto started \
             enabling it, which means the lean CI run is now a duplicate of the \
             --workspace run and NOTHING exercises the sorted-map build any more. \
             The frozen canonical literals still pass, so they no longer prove the \
             signed bytes are independent of the build graph. Find the dependency \
             that pulls in preserve_order and drop it, or move these tests to a \
             crate that can still be built without it. See ADR 0009."
        );
    }
}