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
# obsigil

A mandate-token format and shared-secret **JWT alternative**: a
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.

Verification is symmetric — the same secret [`MandateKey`] both
mints and verifies — so obsigil fits shared-secret (HS256-style)
JWT and JWE use cases, not public-key verification.

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.

## Serialization

Both halves are a single canonical CBOR map (RFC 8949 §4.2).
obsigil owns the encoding, so identical fields mint
byte-identical tokens. Reserved fields take negative integer
keys (`tid` is −1, then `exp`, `aud`, `sub`, `iss`); the
non-negative integers and text strings are the application's. A
verifier rejects any non-canonical encoding — unsorted or
duplicate keys, non-shortest integers, indefinite lengths — and
fails closed on an unrecognized negative key.

## Usage

```rust
use obsigil::{claims, Claims, Clauses, Issuer, MandateKey, Verifier};
use serde::{Deserialize, Serialize};

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

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

// One secret key as hex — `generate_key()` returns 128 lowercase hex
// digits to store as a secret (e.g. an environment variable).
let key_hex = obsigil::generate_key();
let key = MandateKey::from_hex(&key_hex)?;

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

// Front end: read the manifest's claims, no secret needed (advisory).
let advisory: Claims<ClaimData> = claims(&token).expect("present");
assert_eq!(advisory.app().theme, "dark");

// Backend: verify the mandate's clauses against a candidate key (or
// several — trial decryption picks the one that authenticates).
let key = MandateKey::from_hex(&key_hex)?;
let mandate: Clauses<ClauseData> = Verifier::new()
    .key(&key)
    .audience("api")
    .clauses(&token)?;
assert_eq!(mandate.app().role, "admin");
```

A verifier enforces the reserved clauses (the Reserved fields section, §8): 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 canonical-CBOR
fields, 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]LICENSE-APACHE)
- MIT license ([LICENSE-MIT]LICENSE-MIT)

at your option.