obsigil 0.1.0

Mandate-token format: a JWT-like token split into a public manifest and an encrypted mandate (AES-SIV / AES-GCM-SIV), on RustCrypto
Documentation

obsigil

A mandate-token format: a JWT-like token split into a public manifest and an encrypted mandate, joined by a single separator:

token = [ manifest ALG ] SEP [ ALG mandate ]
  • manifest — public claims, readable without a secret
  • mandate — private claims, sealed under a secret key

Each half is an authenticated, deterministically-encrypted ciphertext — AES-SIV (RFC 5297, code 0) or AES-GCM-SIV (RFC 8452, code 1) — built directly on RustCrypto. Only authenticated AEADs are ever compiled in, so an unauthenticated mandate is structurally unrepresentable.

The two halves are independent and have disjoint audiences:

  • the mandate is sealed under a secret 64-byte [MandateKey] (confidential + authenticated); the front end forwards only this half to the backend, which decrypts and enforces it;
  • the manifest is sealed keyless (a public, spec-pinned key), giving tamper-evidence only — anyone can read or forge it, so the front end opens it for UI and treats it as advisory.

Nothing binds the halves cryptographically, and nothing needs to: a forged manifest only misleads the attacker's own UI, while every backend decision rests on the unforgeable mandate. The single separator both joins the halves and names the token's text encoding — . for base64url, ~ for lowercase hex — so the split is unambiguous either way. Either half may be empty, so a manifest-only (manifest.) or mandate-only (.mandate) token is valid.

Serializations

Each half names its serialization with a sealed one-byte tag: j JSON · t TOML · c CBOR. JSON is the mandatory default; TOML and CBOR are behind the toml / cbor features. The spec deliberately excludes any format whose decoder can execute code or build arbitrary objects (e.g. Perl eval, YAML full-load), since the manifest is attacker-forgeable.

Usage

use obsigil::{open_manifest, Issuer, Mandate, MandateKey, Manifest, Verifier};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Access { role: String }

#[derive(Serialize, Deserialize)]
struct Display { theme: String }

// A 64-byte a-tier secret (e.g. from MandateKey::generate()).
let key = MandateKey::from_bytes([42u8; 64])?;

// Issuer: mint a token. The mandate carries the authoritative
// claims; the optional manifest carries advisory ones.
let token = Issuer::new(key)
    .mandate(&Access { role: "admin".into() })
    .exp(4_000_000_000)
    .audience(["api"])
    .subject("u42")
    .manifest("auth.example", &Display { theme: "dark".into() })
    .mint()?;

// Front end: read the manifest, no secret needed (advisory).
let manifest: Manifest<Display> = open_manifest(&token).expect("present");
assert_eq!(manifest.app().theme, "dark");

// Backend: verify the mandate against a candidate key (or
// several — trial decryption picks the one that authenticates).
let key = MandateKey::from_bytes([42u8; 64])?;
let mandate: Mandate<Access> = Verifier::new()
    .key(&key)
    .audience("api")
    .verify(&token)?;
assert_eq!(mandate.app().role, "admin");

A verifier enforces the reserved clauses (spec §11): a present mandate MUST carry exp (rejected once now >= exp, with optional leeway) and a UUIDv7 tid; a present aud is checked for membership against the verifier's identifier in constant time. Every rejection collapses to one opaque Error — the granular cause is available via Error::reason() for internal logging only, never to the bearer.

Status

Pre-1.0; the API may still change before 1.0. The mint/verify core is complete: reserved-field enforcement (exp, aud, tid, manifest iss), multi-key trial decryption, the ./base64url and ~/hex text encodings, and JSON/TOML/CBOR serializations, validated against the cross-language obsigil test vectors. Built on pre-1.0 RustCrypto AEADs.

License

Licensed under either of

at your option.