obsigil 0.4.0

A shared-secret JWT alternative: a mandate-token format splitting a public, advisory manifest from a secret-sealed, authenticated mandate (AES-SIV / AES-GCM-SIV), with fields in canonical CBOR
Documentation
//! Cross-implementation conformance against the language-agnostic
//! `obsigil-test-vectors` (the Conformance and test vectors section, §13). Enable with
//! `--features conformance,gcm-siv`.
//!
//! The vectors are vendored as a git submodule at `tests/vectors` (what CI
//! checks out); override with `OBSIGIL_TEST_VECTORS`, or fall back to the
//! sibling `obsigil-test-vectors` repo in a plain group checkout. If none
//! is present the tests skip rather than fail. The vectors are
//! byte-level (octets -> token), so this harness is serialization-agnostic:
//! each `octets` field is already a half's canonical CBOR plaintext (the Serialization rules, §7),
//! sealed and matched as raw bytes.

#![cfg(all(feature = "conformance", feature = "gcm-siv"))]

use std::path::{Path, PathBuf};
use std::time::Duration;

use obsigil::lowlevel::{self, Alg, Encoding, MANIFEST_KEY};
use obsigil::{claims, MandateKey, Verifier};
use serde_json::Value;

/// The published test mandate key (see the vectors' README).
const MANDATE_TEST_KEY_HEX: &str =
    "a341adc813cfa493412cda5900fa4ec83f20a6cdea4fe5c759f7ccdb7ffbec51\
e01d2ce90c592909adb2ac1cad771790353f439ac86e9b113a17f7c57f0684b0";

fn vectors_dir() -> Option<PathBuf> {
    if let Ok(p) = std::env::var("OBSIGIL_TEST_VECTORS") {
        let p = PathBuf::from(p);
        return p.is_dir().then_some(p);
    }
    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    // Vendored as a submodule at `obsigil/tests/vectors` (what CI checks
    // out); use it only when actually populated.
    let submodule = manifest.join("tests/vectors");
    if submodule.join("test-vectors.jsonl").is_file() {
        return Some(submodule);
    }
    let sibling = manifest.join("../../obsigil-test-vectors");
    sibling.is_dir().then_some(sibling)
}

fn read_vectors(dir: &Path, name: &str) -> Vec<Value> {
    let text = std::fs::read_to_string(dir.join(name)).expect("read vectors file");
    text.lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| serde_json::from_str(l).expect("parse vector line"))
        .collect()
}

fn key_for(role: &str) -> [u8; 64] {
    let hex = match role {
        "manifest" => return MANIFEST_KEY,
        "mandate" => MANDATE_TEST_KEY_HEX,
        other => other,
    };
    lowlevel::decode(hex, Encoding::Hex)
        .expect("key hex")
        .try_into()
        .expect("64-byte key")
}

fn encoding_of(s: &str) -> Encoding {
    match s {
        "b64" => Encoding::B64,
        "hex" => Encoding::Hex,
        other => panic!("unknown encoding {other}"),
    }
}

fn alg_of(s: &str) -> Alg {
    Alg::from_code(s.chars().next().unwrap()).expect("registered alg")
}

#[test]
fn positives_reproduce_bidirectionally() {
    let Some(dir) = vectors_dir() else {
        // A green run without the vectors does NOT establish conformance. CI
        // sets OBSIGIL_REQUIRE_VECTORS to turn this silent skip into a failure.
        assert!(
            std::env::var_os("OBSIGIL_REQUIRE_VECTORS").is_none(),
            "OBSIGIL_REQUIRE_VECTORS is set but obsigil-test-vectors was not found \
             (set OBSIGIL_TEST_VECTORS or place the sibling checkout)"
        );
        eprintln!("obsigil-test-vectors not found; skipping conformance");
        return;
    };
    let vectors = read_vectors(&dir, "test-vectors.jsonl");
    assert!(!vectors.is_empty(), "no positive vectors");

    for v in &vectors {
        let encoding = encoding_of(v["encoding"].as_str().unwrap());
        let mut left = String::new();
        let mut right = String::new();

        for role in ["manifest", "mandate"] {
            let Some(half) = v.get(role).filter(|h| !h.is_null()) else {
                continue;
            };
            let alg_str = half["alg"].as_str().unwrap();
            let alg = alg_of(alg_str);
            let key = key_for(role);
            let octets = lowlevel::decode(half["octets"].as_str().unwrap(), Encoding::Hex).unwrap();

            // seal direction: octets -> sealed -> encoded text.
            let text = lowlevel::encode(&lowlevel::seal(&octets, &key, alg).unwrap(), encoding);
            // open direction: text -> decoded -> octets.
            let reopened =
                lowlevel::open(&lowlevel::decode(&text, encoding).unwrap(), &key, alg).unwrap();
            assert_eq!(reopened, octets, "open != octets for {role}");

            if role == "manifest" {
                left = format!("{text}{alg_str}");
            } else {
                right = format!("{alg_str}{text}");
            }
        }

        let sep = encoding.separator();
        assert_eq!(
            format!("{left}{sep}{right}"),
            v["token"].as_str().unwrap(),
            "assembled token mismatch"
        );
    }
}

#[test]
fn negatives_are_rejected() {
    let Some(dir) = vectors_dir() else {
        // A green run without the vectors does NOT establish conformance. CI
        // sets OBSIGIL_REQUIRE_VECTORS to turn this silent skip into a failure.
        assert!(
            std::env::var_os("OBSIGIL_REQUIRE_VECTORS").is_none(),
            "OBSIGIL_REQUIRE_VECTORS is set but obsigil-test-vectors was not found \
             (set OBSIGIL_TEST_VECTORS or place the sibling checkout)"
        );
        eprintln!("obsigil-test-vectors not found; skipping conformance");
        return;
    };
    let vectors = read_vectors(&dir, "negative-test-vectors.jsonl");
    assert!(!vectors.is_empty(), "no negative vectors");

    for v in &vectors {
        let op = v["op"].as_str().unwrap();
        let token = v["token"].as_str().unwrap();
        let rejected = match op {
            "parse" => lowlevel::parse(token).is_none(),
            // The vectors' op name stays `open-manifest`; the API is `claims`.
            "open-manifest" => claims::<Value>(token).is_none(),
            "verify" => {
                let key = key_for(v.get("key").and_then(|k| k.as_str()).unwrap_or("mandate"));
                let mk = MandateKey::from_bytes(key).expect("vector mandate key");
                let mut ver = Verifier::new().key(&mk);
                if let Some(now) = v.get("now").and_then(Value::as_i64) {
                    ver = ver.now(now);
                }
                if let Some(aud) = v.get("audience").and_then(Value::as_str) {
                    ver = ver.audience(aud);
                }
                if let Some(leeway) = v.get("leeway").and_then(Value::as_u64) {
                    ver = ver.leeway(Duration::from_secs(leeway));
                }
                ver.clauses::<Value>(token).is_err()
            }
            other => panic!("unknown op {other}"),
        };
        assert!(
            rejected,
            "should reject ({}): {token}",
            v["reason"].as_str().unwrap_or("")
        );
    }
}