polyc-crypto 2026.8.0

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
Documentation
//! Stateless, signed, DERIVABLE rotation grants for a web-session family.
//!
//! A grant is a signed statement about `(family_id, generation)` — never
//! random material a store has to remember, and never a hash of one. It is
//! [`mint_grant`]'s output, `base64url(payload_json) ++ "." ++
//! base64url(signature)`, verified by [`verify_grant`] against only the
//! signer's public key. This is deliberately the exact shape
//! [`crate::session`]'s [`crate::session::mint_session`]/
//! [`crate::session::verify_session`] pair already uses, domain-separated by
//! its own prefix (`GRANT_DOMAIN_PREFIX`) rather than a new mechanism.
//!
//! The grant role has a private key distinct from browser sessions and every
//! approval role, so compromise cannot mint another artifact family.
//!
//! **Determinism is the property this module exists to provide, and it is
//! load-bearing.** [`mint_grant`] is a pure function of `(family_id,
//! generation)` under a fixed signer: the same pair always produces the
//! identical grant string, because ed25519 signing is itself deterministic
//! (RFC 8032) and the payload's JSON encoding is a fixed field order, never
//! sourced from a clock or RNG. This is what lets a session family's refresh
//! rule answer a replayed, one-generation-stale grant by returning the
//! CURRENT generation's grant — recomputed, not looked up — so every
//! concurrent caller presenting the same stale grant converges on the same
//! successor rather than each minting (and orphaning) a distinct one. A
//! grant that depended on issue time or any other varying input could not
//! serve that role: rule 2 of the family's rotation policy depends on being
//! able to reconstruct the CURRENT grant from state alone
//! (`docs/design/web-session-lifetime.md`).
//!
//! Like [`crate::session`], this module owns only the grant's cryptographic
//! shape — it holds no store, runs no rotation policy, and knows nothing
//! about cookies or HTTP. `crates/session-family`'s store calls
//! [`mint_grant`]/[`verify_grant`] directly and owns the policy on top.

use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;

use crate::signing_role::{
    RoleTrustSet, SigningRole as _, WebSessionGrantRole, WebSessionGrantSigner,
};

/// Domain-separation prefix prepended to a grant's signed bytes.
///
/// A trailing NUL, matching [`crate::session::SESSION_DOMAIN_PREFIX`], so no
/// legal JSON body can extend it into a prefix collision. Distinct from
/// every other signed-artifact prefix this signer produces — in particular
/// from the session-token prefix itself — so a grant can never verify as a
/// session token, or vice versa, even if custody is misconfigured with the
/// same raw key for both roles. Both share the same
/// `base64url(json).base64url(sig)` wire shape. See [`crate::session`]'s
/// `domain_confusion_is_rejected` test for the session-side half of this
/// property; this module's own `domain_confusion_is_rejected` proves the
/// converse.
const GRANT_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session-grant.v1\0";

/// A verified grant's claims: which family it names, and at which
/// generation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantClaims {
    /// Stable signing-role issuer.
    pub issuer: String,
    /// Exact key identity selected from the grant trust set.
    pub key_id: String,
    /// The session family this grant names.
    pub family_id: String,
    /// The generation this grant was minted for.
    pub generation: u64,
}

/// The wire shape of a grant's payload segment — plain, unencrypted JSON
/// (readable by anyone who holds the grant; it carries no secret),
/// canonicalized field-by-field on the verify side rather than trusted as
/// raw bytes, mirroring [`crate::session`]'s `WirePayload`/`session_canonical`
/// split.
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct WirePayload {
    issuer: String,
    key_id: String,
    family_id: String,
    generation: u64,
}

/// Serialize `payload` once and return both halves the wire needs: the JSON
/// body that becomes the grant's payload segment, and the
/// `GRANT_DOMAIN_PREFIX`-prefixed bytes the signature covers.
///
/// One function rather than two so mint and verify can never disagree about
/// either half. [`verify_grant`] recomputes this from PARSED fields, never
/// from the raw decoded payload bytes trusted as-is, so a re-keyed JSON object
/// that happens to decode the same cannot carry a valid signature.
///
/// # Panics
///
/// Panics if `WirePayload` fails to serialize. Unreachable: it is a `String`
/// and a `u64`, neither of which `serde_json` can refuse.
fn grant_parts(payload: &WirePayload) -> (String, Vec<u8>) {
    let body = serde_json::to_string(payload).expect("WirePayload serializes (a String and a u64)");
    let mut signed = Vec::with_capacity(GRANT_DOMAIN_PREFIX.len() + body.len());
    signed.extend_from_slice(GRANT_DOMAIN_PREFIX);
    signed.extend_from_slice(body.as_bytes());
    (body, signed)
}

/// Mint a stateless, signed, DERIVABLE grant naming `family_id` at
/// `generation`.
///
/// Deterministic: calling this again with the same `(family_id,
/// generation)` under the same `signer` returns the byte-identical string —
/// see the module doc for why this is load-bearing rather than incidental.
/// The token is `base64url(payload_json) ++ "." ++ base64url(signature)`,
/// exactly mirroring [`crate::session::mint_session`]'s wire shape under a
/// different domain prefix.
///
/// # Panics
///
/// Panics only if `WirePayload` fails to serialize, which cannot happen —
/// see `grant_parts`.
#[must_use]
pub fn mint_grant(signer: &WebSessionGrantSigner, family_id: &str, generation: u64) -> String {
    let payload = WirePayload {
        issuer: WebSessionGrantRole::ISSUER.to_owned(),
        key_id: signer.identity().key_id().to_owned(),
        family_id: family_id.to_owned(),
        generation,
    };
    let (payload_json, signed_bytes) = grant_parts(&payload);
    let signature = signer.sign(&signed_bytes);
    format!(
        "{}.{}",
        URL_SAFE_NO_PAD.encode(payload_json.as_bytes()),
        URL_SAFE_NO_PAD.encode(signature)
    )
}

/// Verify a grant minted by [`mint_grant`], returning its [`GrantClaims`] on
/// success.
///
/// Fail-closed on ALL of: malformed shape (not exactly one `.`), undecodable
/// base64/JSON, a missing/wrong-typed field, or a signature that does not
/// verify against `signer_public_key` under the domain-separated canonical
/// (`grant_parts` — so a signature minted for any OTHER artifact kind
/// this signer produces, including a session token, can never verify here).
/// Unlike [`crate::session::verify_session`], there is no expiry or denylist
/// check: a grant's validity is entirely a property of the family store's
/// OWN state (which generation is current, whether the family is
/// tombstoned) — this function answers only "is this a genuine, unmodified
/// grant", and the caller applies the family's rotation policy on top.
#[must_use]
pub fn verify_grant(signer_public_key: &[u8], grant: &str) -> Option<GrantClaims> {
    let trusted_signers =
        RoleTrustSet::<WebSessionGrantRole>::from_public_keys(vec![signer_public_key.to_vec()])
            .ok()?;
    verify_grant_with_trust(&trusted_signers, grant)
}

/// Verifies a grant against current and retired role keys.
#[must_use]
pub fn verify_grant_with_trust(
    trusted_signers: &RoleTrustSet<WebSessionGrantRole>,
    grant: &str,
) -> Option<GrantClaims> {
    let (payload_b64, signature_b64) = grant.split_once('.')?;
    if payload_b64.is_empty() || signature_b64.is_empty() || signature_b64.contains('.') {
        return None;
    }
    let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
    let signature = URL_SAFE_NO_PAD.decode(signature_b64).ok()?;
    let payload: WirePayload = serde_json::from_slice(&payload_bytes).ok()?;
    let (_, signed_bytes) = grant_parts(&payload);
    if payload.issuer != WebSessionGrantRole::ISSUER
        || !trusted_signers.verify(&payload.key_id, &signed_bytes, &signature)
    {
        return None;
    }
    Some(GrantClaims {
        issuer: payload.issuer,
        key_id: payload.key_id,
        family_id: payload.family_id,
        generation: payload.generation,
    })
}

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

    use super::*;

    fn test_signer() -> WebSessionGrantSigner {
        WebSessionGrantSigner::from_key_bytes(&[7u8; 32])
            .expect("valid 32-byte ed25519 key material")
    }

    #[test]
    fn round_trips() {
        let signer = test_signer();
        let grant = mint_grant(&signer, "family-1", 3);

        let claims = verify_grant(&signer.public_key_bytes(), &grant)
            .expect("a freshly minted grant verifies");
        assert_eq!(claims.family_id, "family-1");
        assert_eq!(claims.generation, 3);
    }

    #[test]
    fn minting_the_same_pair_twice_is_byte_identical() {
        // The load-bearing property: rule 2 of the family's rotation policy
        // recomputes rather than looks up the current grant, so two calls
        // with the same (family_id, generation) MUST agree exactly.
        let signer = test_signer();
        let first = mint_grant(&signer, "family-1", 3);
        let second = mint_grant(&signer, "family-1", 3);
        assert_eq!(first, second);
    }

    #[test]
    fn tampered_generation_is_rejected() {
        let signer = test_signer();
        let grant = mint_grant(&signer, "family-1", 3);
        let (payload_b64, sig_b64) = grant.split_once('.').expect("shape");
        let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
        let mut payload: WirePayload =
            serde_json::from_slice(&payload_bytes).expect("valid payload");
        payload.generation = 4;
        let tampered_bytes = serde_json::to_string(&payload).expect("serializes");
        let tampered = format!(
            "{}.{sig_b64}",
            URL_SAFE_NO_PAD.encode(tampered_bytes.as_bytes())
        );

        assert!(
            verify_grant(&signer.public_key_bytes(), &tampered).is_none(),
            "a grant whose generation was changed after signing must not verify"
        );
    }

    #[test]
    fn tampered_family_id_is_rejected() {
        let signer = test_signer();
        let grant = mint_grant(&signer, "family-1", 3);
        let (payload_b64, sig_b64) = grant.split_once('.').expect("shape");
        let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
        let mut payload: WirePayload =
            serde_json::from_slice(&payload_bytes).expect("valid payload");
        payload.family_id = "family-2".to_owned();
        let tampered_bytes = serde_json::to_string(&payload).expect("serializes");
        let tampered = format!(
            "{}.{sig_b64}",
            URL_SAFE_NO_PAD.encode(tampered_bytes.as_bytes())
        );

        assert!(
            verify_grant(&signer.public_key_bytes(), &tampered).is_none(),
            "a grant whose family_id was changed after signing must not verify"
        );
    }

    #[test]
    fn valid_signatures_with_unknown_role_identity_fail_closed() {
        let signer = test_signer();
        let original = WirePayload {
            issuer: WebSessionGrantRole::ISSUER.to_owned(),
            key_id: signer.identity().key_id().to_owned(),
            family_id: "family-1".to_owned(),
            generation: 3,
        };

        for payload in [
            WirePayload {
                issuer: "polychrome.control.session".to_owned(),
                ..original.clone()
            },
            WirePayload {
                key_id: "unknown-key-id".to_owned(),
                ..original.clone()
            },
        ] {
            let (body, canonical) = grant_parts(&payload);
            let forged = format!(
                "{}.{}",
                URL_SAFE_NO_PAD.encode(body),
                URL_SAFE_NO_PAD.encode(signer.sign(&canonical))
            );
            assert!(verify_grant(&signer.public_key_bytes(), &forged).is_none());
        }
    }

    #[test]
    fn a_grant_minted_before_rotation_verifies_only_with_historical_trust() {
        let retired = WebSessionGrantSigner::from_seed(91);
        let current = WebSessionGrantSigner::from_seed(92);
        let grant = mint_grant(&retired, "family-before-rotation", 4);
        let current_only = RoleTrustSet::<WebSessionGrantRole>::current(&current);
        let historical = RoleTrustSet::<WebSessionGrantRole>::checked(vec![
            current.identity(),
            retired.identity(),
        ])
        .expect("valid rotation history");

        assert!(verify_grant_with_trust(&current_only, &grant).is_none());
        assert!(verify_grant_with_trust(&historical, &grant).is_some());
    }

    #[test]
    fn garbage_input_returns_none() {
        let signer = test_signer();
        assert!(verify_grant(&signer.public_key_bytes(), "").is_none());
        assert!(verify_grant(&signer.public_key_bytes(), "not-a-grant-at-all").is_none());
        assert!(verify_grant(&signer.public_key_bytes(), "a.b.c").is_none());
        assert!(verify_grant(&signer.public_key_bytes(), ".").is_none());
    }

    #[test]
    fn domain_confusion_is_rejected() {
        // A session token minted by `crate::session::mint_session` under the
        // same raw key must never verify as a grant, proving the domain
        // prefixes actually separate the two artifact kinds rather than
        // merely happening not to collide in these tests.
        let signer = test_signer();
        let session_token = crate::session::mint_session(
            &signer.relabel_for_test(),
            &crate::session::SessionSubject::Persona {
                persona_id: "persona-1".to_owned(),
            },
            &[crate::session::SessionScope::ExplorerRead],
            1_700_000_000_000,
            8 * 60 * 60 * 1000,
        );

        assert!(
            verify_grant(&signer.public_key_bytes(), &session_token).is_none(),
            "a session token must not verify as a grant"
        );
    }
}