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 ;
use ;
// A 64-byte a-tier secret (e.g. from MandateKey::generate()).
let key = from_bytes?;
// Issuer: mint a token. The mandate carries the authoritative
// claims; the optional manifest carries advisory ones.
let token = new
.mandate
.exp
.audience
.subject
.manifest
.mint?;
// Front end: read the manifest, no secret needed (advisory).
let manifest: = open_manifest.expect;
assert_eq!;
// Backend: verify the mandate against a candidate key (or
// several — trial decryption picks the one that authenticates).
let key = from_bytes?;
let mandate: = new
.key
.audience
.verify?;
assert_eq!;
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
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.