aitp-tct 0.2.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.1"`. Required as of spec rc.4 (the rc.2 KAT
    /// body that omitted this field has been re-minted).
    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 (rc.4), both `revocation_list` and
/// `signature` are REQUIRED. `signature` is base64url over the JCS
/// canonical bytes of `{"revocation_list": {...}}` — the wrapper
/// minus the signature field — per the spec rc.4 KAT pattern.
#[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,
}

/// Internal signing view: the inner [`RevocationList`] body itself,
/// **without** the `{"revocation_list": …}` wrapper key. The spec
/// (rc.4 `kat-revocation-001` re-mint) signs the inner body directly
/// — mirroring the multi-hop and session-bundle conventions where
/// the wrapper key is HTTP-transport sugar but is not part of the
/// canonical signing input.
type RevocationListSigningView<'a> = &'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 spec rc.4 `kat-revocation-001`.
pub fn sign_revocation_list(
    body: RevocationList,
    issuer_key: &AitpSigningKey,
) -> Result<RevocationListEnvelope, TctError> {
    let view: RevocationListSigningView = &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/0.1" {
        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);
    }

    // Reconstruct the canonical bytes the issuer signed.
    //
    // The spec's signing convention shifted between rc.3 and rc.4:
    // older fixtures (rev-001, rev-002) sign over the **wrapped**
    // `{"revocation_list": {...}}` envelope, newer fixtures
    // (rev-003 and onward) sign over the **inner** body. We try
    // the new form first (canonical going forward) and fall back
    // to the legacy wrapped form to preserve interop with peers
    // that haven't re-minted yet.
    let pubkey =
        AitpVerifyingKey::from_aid(&envelope.revocation_list.issuer).map_err(TctError::Crypto)?;
    let sig = Signature::parse(&envelope.signature).map_err(|_| TctError::SignatureInvalid)?;

    let inner: RevocationListSigningView = &envelope.revocation_list;
    let canonical_inner = jcs::canonicalize_serializable(inner)
        .map_err(|e| TctError::Canonicalization(e.to_string()))?;
    if pubkey
        .verify(&Sha256::digest(&canonical_inner), &sig)
        .is_ok()
    {
        return Ok(());
    }

    // Legacy wrapped form, signed bytes were
    // `{"revocation_list": {...inner body...}}`.
    #[derive(Serialize)]
    struct LegacyWrapped<'a> {
        revocation_list: &'a RevocationList,
    }
    let wrapped = LegacyWrapped {
        revocation_list: &envelope.revocation_list,
    };
    let canonical_wrapped = jcs::canonicalize_serializable(&wrapped)
        .map_err(|e| TctError::Canonicalization(e.to_string()))?;
    pubkey
        .verify(&Sha256::digest(&canonical_wrapped), &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.1".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 spec rc.4
        // schemas/conformance/known-answer/jcs-sha256.json. Re-minted
        // post-rc.3 to include the now-REQUIRED `version` field.
        let body = RevocationList {
            version: "aitp/0.1".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 = &body;
        let canonical = jcs::canonicalize_serializable(view).unwrap();
        // Pinned canonical bytes: inner body (no `{"revocation_list":…}`
        // wrapper) per the spec's rc.4-onward signing convention.
        let expected_hex = "7b22656e7472696573223a5b7b226a7469223a2235353065383430302d653239622d343164342d613731362d343436363535343430303030222c227265766f6b65645f6174223a313731313930313030307d5d2c22657870697265735f6174223a313731313930333630302c22697373756572223a226169643a7075626b65793a4f326f6e764d3632704331696f366a514b6d384e6332557946586364346b4f6d4f7342496f59745a32696b222c227075626c69736865645f6174223a313731313930303030302c2276657273696f6e223a22616974702f302e31227d";
        assert_eq!(
            hex::encode(&canonical),
            expected_hex,
            "canonical bytes diverge from spec rc.4 kat-revocation-001"
        );
        let digest = Sha256::digest(&canonical);
        // SHA-256 of the inner-only canonical above.
        assert_eq!(
            hex::encode(digest),
            "a825796920dcd964489b8417a92061664dccd976b8271e6b01d80b49973ace74"
        );
    }
}