polyc-crypto 2026.8.0

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
Documentation
//! Routine observation handles (`#1494`).
//!
//! `routine_list` is the read tool every routine lifecycle mutation depends
//! on (INV-RL4): a caller must *observe* a routine through it before
//! `routine_pause`/`resume`/`delete`/`fire` may act on it — never by
//! hallucinating or guessing a routine's human-facing name. For each routine
//! it lists, `routine_list` mints an **observation handle**: a keyed MAC over
//! the canonical tuple `(conversation_id, routine_uid, expiry)`. The handle
//! is a capability the model may see and relay — it is NOT a secret (do not
//! harden it into one); its property is conversation-binding and liveness,
//! not confidentiality. A later mutation verb presents the handle back, and
//! the control plane verifies it statelessly (no lookup, no store) before
//! ever touching the named routine.
//!
//! This lifts the construction the email edge's approval-by-reply nonce uses
//! (`crates/email/src/approval.rs`: `issue_nonce`/`verify_nonce` —
//! HMAC-SHA256 over a `0x1f`-separator-framed tuple, constant-time compare,
//! independent expiry check) rather than importing it: the edge's nonce is
//! keyed by an edge-local secret and binds a *decision*
//! `(conv_id, approval_id, decision, approver, expiry)`; this handle is keyed
//! by a NEW control-plane-only secret and binds a *routine observation*
//! `(conversation_id, routine_uid, expiry)`. Two different keys, two
//! different tuples — a token minted for one can never verify as the other,
//! even before the field shapes diverge.
//!
//! Binding `routine_uid` (the routine's stable Kubernetes `uid`), never its
//! human-facing name, is what makes INV-RL5 hold: deleting a routine and
//! recreating one under the same name mints a fresh `uid`, so a handle minted
//! before the delete never verifies against the new object.

use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

/// Field separator for the canonical tuple — a control byte that cannot
/// appear in any of the textual fields, so the joined string is unambiguous
/// (no field can inject a separator to shift the boundary between two
/// neighbors and collide with a different split of the same bytes).
const SEP: u8 = 0x1f;

/// Mint an observation handle over `(conversation_id, routine_uid,
/// expiry_unix)`, keyed by `secret`.
///
/// Returns the lowercase-hex HMAC-SHA256 of the separator-framed tuple.
/// `expiry_unix` is the wall-clock second after which the handle is dead, so
/// a handle relayed by the model (or leaked into a transcript) cannot be
/// presented back indefinitely.
///
/// # Panics
///
/// Never — HMAC-SHA256 accepts a key of any length.
#[must_use]
pub fn mint_observation_handle(
    secret: &[u8],
    conversation_id: &str,
    routine_uid: &str,
    expiry_unix: u64,
) -> String {
    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
    mac.update(conversation_id.as_bytes());
    mac.update(&[SEP]);
    mac.update(routine_uid.as_bytes());
    mac.update(&[SEP]);
    mac.update(expiry_unix.to_string().as_bytes());
    crate::hex::lower(&mac.finalize().into_bytes())
}

/// Verify an observation handle presented back by a mutation verb
/// (`routine_pause`/`resume`/`delete`/`fire`, INV-RL4).
///
/// Recomputes the expected handle for `(conversation_id, routine_uid,
/// expiry_unix)` and compares it to `handle` in **constant time**, and
/// independently requires `now_unix <= expiry_unix`. Returns `true` only when
/// both hold: `handle` is authentic for exactly this tuple AND it has not
/// expired. The caller is responsible for confirming `routine_uid` still
/// names a LIVE routine (INV-RL5) — this function only proves the handle was
/// minted for that uid, not that the uid still exists.
///
/// Pure (no clock, no I/O — `now_unix` is supplied) so the full matrix of
/// valid / expired / tampered / wrong-conversation / wrong-routine cases is
/// unit-testable.
///
/// # Panics
///
/// Never — [`mint_observation_handle`] always returns valid lowercase hex, so
/// the internal re-decode of the expected handle cannot fail.
#[must_use]
pub fn verify_observation_handle(
    secret: &[u8],
    conversation_id: &str,
    routine_uid: &str,
    expiry_unix: u64,
    handle: &str,
    now_unix: u64,
) -> bool {
    if now_unix > expiry_unix {
        return false;
    }
    let Some(provided) = crate::hex::decode(handle) else {
        return false;
    };
    let expected = mint_observation_handle(secret, conversation_id, routine_uid, expiry_unix);
    // `expected` is hex of a fixed-width MAC; decode for a raw constant-time
    // compare so length differences don't short-circuit a timing channel.
    let expected_raw =
        crate::hex::decode(&expected).expect("mint_observation_handle returns valid hex");
    expected_raw.as_slice().ct_eq(&provided).into()
}

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

    use super::*;

    const SECRET: &[u8] = b"routine-observation-secret";
    const OTHER_SECRET: &[u8] = b"a-completely-different-secret";
    const CONV: &str = "conv-abc";
    const ROUTINE_UID: &str = "uid-1234";
    const EXPIRY: u64 = 1_700_000_600;

    fn good_handle() -> String {
        mint_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY)
    }

    #[test]
    fn valid_unexpired_handle_verifies() {
        let handle = good_handle();
        assert!(verify_observation_handle(
            SECRET,
            CONV,
            ROUTINE_UID,
            EXPIRY,
            &handle,
            1_700_000_000,
        ));
    }

    #[test]
    fn handle_valid_exactly_at_the_expiry_boundary_but_not_after() {
        let handle = good_handle();
        assert!(
            verify_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY, &handle, EXPIRY),
            "now == expiry must still verify"
        );
        assert!(
            !verify_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY, &handle, EXPIRY + 1),
            "one second past expiry must not verify"
        );
    }

    #[test]
    fn expired_handle_rejected_even_if_authentic() {
        let handle = good_handle();
        assert!(!verify_observation_handle(
            SECRET,
            CONV,
            ROUTINE_UID,
            EXPIRY,
            &handle,
            1_700_000_601,
        ));
    }

    /// Cross-conversation reuse: a handle minted while observing routine
    /// `ROUTINE_UID` from `CONV` must not verify for a DIFFERENT conversation
    /// presenting the exact same routine uid and expiry — the "same
    /// conversation" leg of INV-RL4.
    #[test]
    fn wrong_conversation_invalidates_the_handle() {
        let handle = good_handle();
        assert!(!verify_observation_handle(
            SECRET,
            "conv-other",
            ROUTINE_UID,
            EXPIRY,
            &handle,
            1_700_000_000,
        ));
    }

    /// Cross-routine reuse (the "cross-purpose" case for this artifact, which
    /// carries no separate purpose/decision field the way the email nonce's
    /// `decision` does): a handle minted for one routine must not verify for
    /// a different routine's uid, including a routine recreated under the
    /// SAME human-facing name — the mechanism behind INV-RL5.
    #[test]
    fn wrong_routine_uid_invalidates_the_handle() {
        let handle = good_handle();
        assert!(!verify_observation_handle(
            SECRET,
            CONV,
            "uid-9999",
            EXPIRY,
            &handle,
            1_700_000_000,
        ));
    }

    #[test]
    fn tampered_handle_is_rejected() {
        let mut handle = good_handle();
        let last = handle.pop().unwrap();
        handle.push(if last == 'f' { '0' } else { 'f' });
        assert!(!verify_observation_handle(
            SECRET,
            CONV,
            ROUTINE_UID,
            EXPIRY,
            &handle,
            1_700_000_000,
        ));
    }

    #[test]
    fn non_hex_handle_rejected() {
        assert!(!verify_observation_handle(
            SECRET,
            CONV,
            ROUTINE_UID,
            EXPIRY,
            "not-hex-zz",
            1_700_000_000,
        ));
    }

    /// Field-boundary shift: without the `0x1f` separator framing,
    /// `("ab", "c")` and `("a", "bc")` could hash identically. Framed, moving
    /// the boundary changes the MAC — mirrors the email nonce's own
    /// `separator_framing_resists_field_shifting` test.
    #[test]
    fn field_boundary_shift_resists_collision() {
        let a = mint_observation_handle(SECRET, "ab", "c", EXPIRY);
        let b = mint_observation_handle(SECRET, "a", "bc", EXPIRY);
        assert_ne!(a, b);
    }

    /// Minted under a DIFFERENT key, a handle for the exact same tuple must
    /// not verify against this key — proves the control-plane secret, not
    /// just the tuple shape, is what makes a handle authentic.
    #[test]
    fn a_handle_minted_under_a_different_key_does_not_verify() {
        let handle = mint_observation_handle(OTHER_SECRET, CONV, ROUTINE_UID, EXPIRY);
        assert!(!verify_observation_handle(
            SECRET,
            CONV,
            ROUTINE_UID,
            EXPIRY,
            &handle,
            1_700_000_000,
        ));
    }

    #[test]
    fn changing_the_expiry_the_handle_was_minted_for_invalidates_it() {
        // A handle minted for one expiry must not verify when presented back
        // claiming a different expiry, even a later one that would otherwise
        // still be live — the expiry is part of what's authenticated, not a
        // side channel the caller can freely renegotiate.
        let handle = mint_observation_handle(SECRET, CONV, ROUTINE_UID, EXPIRY);
        assert!(!verify_observation_handle(
            SECRET,
            CONV,
            ROUTINE_UID,
            EXPIRY + 1_000,
            &handle,
            1_700_000_000,
        ));
    }
}