aitp-tct 0.3.0

Trust Context Token (TCT) issuance and verification for AITP
Documentation
//! Signed revocation snapshots (RFC-AITP-0008 §1.5).
//!
//! An issuing peer publishes a periodically-refreshed signed snapshot
//! of every TCT JTI it has revoked. Consuming peers cache the snapshot
//! per `expires_at` and consult it before honoring a TCT. An empty
//! `entries` array is itself a meaningful signed assertion that nothing
//! has been revoked since the previous snapshot — this defends against
//! a network attacker that suppresses fresher snapshots to roll back
//! revocations.

use aitp_core::{jcs, Aid, Timestamp};
use aitp_crypto::{AitpSigningKey, AitpVerifyingKey, Signature};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;

use crate::TctError;

/// Inner body of a signed revocation snapshot.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevocationList {
    /// MUST be `"aitp/0.2"`.
    pub version: String,
    /// The issuing peer's AID. MUST equal the `issuer` of every TCT
    /// covered by `entries`.
    pub issuer: Aid,
    /// Unix timestamp when this snapshot was signed.
    pub published_at: Timestamp,
    /// Unix timestamp after which this snapshot MUST NOT be cached.
    pub expires_at: Timestamp,
    /// Revoked-entry records. MAY be empty.
    pub entries: Vec<RevocationEntry>,
}

/// A single revoked-TCT record.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevocationEntry {
    /// JTI of the revoked TCT.
    pub jti: Uuid,
    /// Unix timestamp when the issuing peer revoked the TCT.
    pub revoked_at: Timestamp,
    /// Optional human-readable reason. Not used in trust decisions.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

/// On-wire envelope: `{"revocation_list": {...}, "signature": "..."}`.
///
/// Per RFC-AITP-0008 §1.5, both `revocation_list` and `signature` are
/// REQUIRED. `signature` is base64url over
/// `sha256(JCS({"revocation_list": {...}}))` — the envelope minus the
/// signature field — per the v0.2 `kat-revocation-001` vector.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevocationListEnvelope {
    /// The signed inner snapshot.
    pub revocation_list: RevocationList,
    /// Issuer's base64url signature over JCS-canonical bytes of
    /// `{"revocation_list": {...}}`.
    pub signature: String,
}

/// Signing view: the wrapped `{"revocation_list": {...}}` form (the
/// envelope minus `signature`), per the v0.2 `kat-revocation-001`
/// vector.
#[derive(Serialize)]
struct RevocationListSigningView<'a> {
    revocation_list: &'a RevocationList,
}

/// Sign a [`RevocationList`] body with the issuer's signing key.
///
/// Returns the on-wire [`RevocationListEnvelope`] with `signature`
/// populated. The signing input is `sha256(JCS({"revocation_list": {...}}))`
/// per the v0.2 `kat-revocation-001` vector.
pub fn sign_revocation_list(
    body: RevocationList,
    issuer_key: &AitpSigningKey,
) -> Result<RevocationListEnvelope, TctError> {
    let view = RevocationListSigningView {
        revocation_list: &body,
    };
    let canonical = jcs::canonicalize_serializable(&view)
        .map_err(|e| TctError::Canonicalization(e.to_string()))?;
    let digest = Sha256::digest(&canonical);
    let sig = issuer_key.sign(&digest);
    Ok(RevocationListEnvelope {
        revocation_list: body,
        signature: sig.into_string(),
    })
}

/// Verify a [`RevocationListEnvelope`].
///
/// 1. `revocation_list.expires_at >= ctx.now` — else `TctError::Expired`.
/// 2. `revocation_list.issuer` resolves to a public key matching
///    `ctx.expected_issuer` (else `TctError::CnfMalformed` — chosen
///    rather than introducing a new error variant for v0.1).
/// 3. `signature` is present and verifies under that public key over
///    `sha256(JCS(envelope without signature))`.
pub fn verify_revocation_list(
    envelope: &RevocationListEnvelope,
    ctx: &VerifyRevocationListContext<'_>,
) -> Result<(), TctError> {
    if envelope.revocation_list.version != aitp_core::PROTOCOL_VERSION {
        return Err(TctError::VersionUnknown);
    }
    if envelope.revocation_list.expires_at.is_in_the_past(ctx.now) {
        return Err(TctError::Expired);
    }
    if &envelope.revocation_list.issuer != ctx.expected_issuer {
        return Err(TctError::CnfMalformed);
    }

    let pubkey =
        AitpVerifyingKey::from_aid(&envelope.revocation_list.issuer).map_err(TctError::Crypto)?;
    let sig = Signature::parse(&envelope.signature).map_err(|_| TctError::SignatureInvalid)?;

    let view = RevocationListSigningView {
        revocation_list: &envelope.revocation_list,
    };
    let canonical = jcs::canonicalize_serializable(&view)
        .map_err(|e| TctError::Canonicalization(e.to_string()))?;
    pubkey
        .verify(&Sha256::digest(&canonical), &sig)
        .map_err(|_| TctError::SignatureInvalid)?;
    Ok(())
}

/// Context for [`verify_revocation_list`].
pub struct VerifyRevocationListContext<'a> {
    /// The AID the verifier expects this snapshot to be from.
    pub expected_issuer: &'a Aid,
    /// Verifier's clock for `expires_at` check.
    pub now: Timestamp,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn issuer_key() -> AitpSigningKey {
        AitpSigningKey::from_seed(&[0xA0; 32])
    }

    fn sample_body(issuer: Aid) -> RevocationList {
        RevocationList {
            version: "aitp/0.2".into(),
            issuer,
            published_at: Timestamp(1_700_000_000),
            expires_at: Timestamp(1_700_003_600),
            entries: vec![RevocationEntry {
                jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
                revoked_at: Timestamp(1_700_001_000),
                reason: None,
            }],
        }
    }

    #[test]
    fn sign_then_verify_round_trips() {
        let key = issuer_key();
        let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
        let ctx = VerifyRevocationListContext {
            expected_issuer: key.aid(),
            now: Timestamp(1_700_001_000),
        };
        verify_revocation_list(&env, &ctx).expect("fresh snapshot verifies");
    }

    #[test]
    fn expired_is_rejected() {
        let key = issuer_key();
        let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
        let ctx = VerifyRevocationListContext {
            expected_issuer: key.aid(),
            now: Timestamp(1_700_999_999),
        };
        assert!(matches!(
            verify_revocation_list(&env, &ctx),
            Err(TctError::Expired)
        ));
    }

    #[test]
    fn wrong_issuer_is_rejected() {
        let key = issuer_key();
        let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
        let other = AitpSigningKey::from_seed(&[0xB0; 32]);
        let ctx = VerifyRevocationListContext {
            expected_issuer: other.aid(),
            now: Timestamp(1_700_001_000),
        };
        assert!(matches!(
            verify_revocation_list(&env, &ctx),
            Err(TctError::CnfMalformed)
        ));
    }

    #[test]
    fn empty_entries_round_trips() {
        let key = issuer_key();
        let mut body = sample_body(key.aid().clone());
        body.entries.clear();
        let env = sign_revocation_list(body, &key).unwrap();
        let ctx = VerifyRevocationListContext {
            expected_issuer: key.aid(),
            now: Timestamp(1_700_001_000),
        };
        verify_revocation_list(&env, &ctx).expect("empty list still verifies");
    }

    #[test]
    fn rfc_kat_canonical_bytes_match() {
        // Vector kat-revocation-001 from the v0.2 spec
        // schemas/conformance/known-answer/jcs-sha256.json: signed view
        // is the wrapped `{"revocation_list": {...}}` form, version
        // literal `aitp/0.2`.
        let body = RevocationList {
            version: "aitp/0.2".into(),
            issuer: Aid::parse("aid:pubkey:O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik").unwrap(),
            published_at: Timestamp(1_711_900_000),
            expires_at: Timestamp(1_711_903_600),
            entries: vec![RevocationEntry {
                jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
                revoked_at: Timestamp(1_711_901_000),
                reason: None,
            }],
        };
        let view = RevocationListSigningView {
            revocation_list: &body,
        };
        let canonical = jcs::canonicalize_serializable(&view).unwrap();
        let expected_hex = "7b227265766f636174696f6e5f6c697374223a7b22656e7472696573223a5b7b226a7469223a2235353065383430302d653239622d343164342d613731362d343436363535343430303030222c227265766f6b65645f6174223a313731313930313030307d5d2c22657870697265735f6174223a313731313930333630302c22697373756572223a226169643a7075626b65793a4f326f6e764d3632704331696f366a514b6d384e6332557946586364346b4f6d4f7342496f59745a32696b222c227075626c69736865645f6174223a313731313930303030302c2276657273696f6e223a22616974702f302e32227d7d";
        assert_eq!(
            hex::encode(&canonical),
            expected_hex,
            "canonical bytes diverge from spec v0.2 kat-revocation-001"
        );
        let digest = Sha256::digest(&canonical);
        assert_eq!(
            hex::encode(digest),
            "739feb36cc2530ad3188f6c3a9ee7459820533382ee24387a8c261787397e0d9"
        );
    }

    #[test]
    fn spec_signed_example_snapshot_verifies() {
        // signed-examples/revocation/kat-keypair-001-snapshot.json:
        // re-mint from the pinned seed and verify byte-stable signature.
        let key = AitpSigningKey::from_seed(&[0u8; 32]); // kat-keypair-001
        let body = RevocationList {
            version: "aitp/0.2".into(),
            issuer: key.aid().clone(),
            published_at: Timestamp(1_711_900_000),
            expires_at: Timestamp(1_711_903_600),
            entries: vec![RevocationEntry {
                jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440099").unwrap(),
                revoked_at: Timestamp(1_711_900_060),
                reason: Some("key_compromised".into()),
            }],
        };
        let env = sign_revocation_list(body, &key).unwrap();
        assert_eq!(
            env.signature,
            "2OYmur9NnrFsrz4Qeso_fGj2Bk0g2y6yNf4H7dqrqEvKZ-YfndY3GavquOIodWGs4EFdgmaHoer0NWc7sPF1DQ",
            "signature diverges from the spec signed-example vector"
        );
        let ctx = VerifyRevocationListContext {
            expected_issuer: key.aid(),
            now: Timestamp(1_711_900_100),
        };
        verify_revocation_list(&env, &ctx).expect("spec vector verifies");
    }
}