Obsigil

Sealed mandate tokens — a shared-secret, encrypted alternative to JWT

Mandate-Token Format Specification version 1.0 draft


Obsigil is a mandate-token format — a single token string split into a public, advisory manifest (display-only claims anyone can read) and a secret-sealed, authoritative mandate (binding clauses the backend enforces). Each half is an authenticated, deterministically-encrypted ciphertext (AES-SIV or AES-GCM-SIV) over a canonical-CBOR field map, so the same fields always mint byte-identical tokens and there is no nonce to manage. Verification is symmetric — one key both mints and verifies — making obsigil a drop-in for the shared-secret (HS256-style) JWT and JWE use cases, without a signature, a JOSE header, or a JSON wire form.

The token

A token is the two halves joined by a single separator that names the text encoding, with a one-character algorithm code on each side naming that half's cipher:

token = [ manifest ALG ] SEP [ ALG mandate ]
SEP   = "." / "~"     ; "." = b64, "~" = hex
ALG   = "0" / "1"     ; 0 = AES-SIV, 1 = AES-GCM-SIV

So a complete b64 token, both halves AES-SIV, looks like this — manifest, 0.0, mandate:

-WhixIj8T6kxljCMVsmY0OGOSZh68pQe8a6U9ZuRBjqSnUN96lSHeRFa0.03MK_shWrguB4IXqoTAftVxrdTTvjTNSCRWmActcPDHf__V6pRHvv-O-6wb2PfgOL0W2lkzCYZr-1AoE_1Vi2cs9gFNy1kzI

Both halves default to AES-SIV (algorithm code 0) — required in every implementation, and the best performer for small and standard-sized tokens. AES-GCM-SIV (code 1) is optional — usable where both sides support it — and scales better with input size, outperforming AES-SIV on very large tokens. The separator carries the text encoding (. b64, ~ hex) independently of either half's cipher.

The front end opens the manifest for display; the backend receives the mandate (.0mandate) and enforces it. The two halves are not cryptographically bound — and don't need to be, because the manifest is advisory and a reader MUST NOT trust its claims. All enforcement rests on the mandate.

Features

Specification

The 1.0 draft is settled. Available in HTML and PDF.

Document Description Download
Mandate-Token Format
version 1.0 draft
wire format, the two algorithms, reserved fields, security model, and a cross-language API conformance profile HTML · PDF

Quick Start

Rust is the reference implementation. Go and Python are also published; TypeScript and Perl implementations are in progress and will be listed here as each is published.

Install:

cargo add obsigil               # AES-SIV (code 0); add --features gcm-siv for AES-GCM-SIV (code 1)
cargo add serde --features derive
pip install obsigil
go get obsigil.org/go/obsigil

Generate a 64-byte mandate key — the same bytes mint and verify, so provision them to issuer and verifier alike:

// Fresh 64-byte key from the OS CSPRNG (zeroized on drop):
let key = obsigil::generate_key();        // or MandateKey::generate()

// Or wrap bytes you loaded from secure storage:
let key = MandateKey::from_bytes([42u8; 64])?;
import obsigil

# Fresh 64-byte key from the OS CSPRNG; provision the same
# bytes to issuer and verifier. The example below pins demo
# bytes instead.
key = obsigil.generate_key()
// 64-byte mandate key from the OS CSPRNG; provision the same bytes to
// issuer and verifier. The example below pins demo bytes instead.
key, err := obsigil.GenerateKey() // ([]byte, error), len 64

Mint a token, then read it from both sides — the front end opens the public manifest with no secret, the backend authenticates the mandate and gets typed clauses back:

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

#[derive(Serialize, Deserialize)]
struct ClauseData { scope: String }   // authoritative mandate clauses (app data)

#[derive(Serialize, Deserialize)]
struct ClaimData { name: String }  // advisory manifest claims (app data)

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The 64-byte shared secret, provisioned to issuer and verifier
    // alike. In production create it once with `obsigil::generate_key()`
    // and load the same bytes on each side; pinned here for a
    // reproducible example.
    let secret = [42u8; 64];

    // --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
    let token = Issuer::new(MandateKey::from_bytes(secret)?)
        .clauses(&ClauseData { scope: "read:invoices".into() }) // sealed, binding
        .exp(4_000_000_000)                  // REQUIRED absolute expiry
        .subject("user-42")                  // optional sub clause
        .audience(["invoice-api"])           // optional aud clause
        .manifest("auth.example",  // public advisory half
                  &ClaimData { name: "Ada".into() })
        .mint()?;                            // tid: fresh UUIDv7
    println!("{token}");

    // --- Front end: read the public manifest, no secret (advisory) ---
    let advisory: Claims<ClaimData> = claims(&token).expect("manifest present");
    assert_eq!(advisory.issuer(), "auth.example");
    assert_eq!(advisory.app().name, "Ada");

    // --- Backend: authenticate the mandate and read its clauses ---
    let verify_key = MandateKey::from_bytes(secret)?;
    let clauses: Clauses<ClauseData> = Verifier::new()
        .key(&verify_key)
        .audience("invoice-api")
        .now(1_000_000_000) // pin "now"; omit to read the system clock
        .clauses(&token)?;  // checks exp, aud, UUIDv7 tid; opaque Error on failure

    assert_eq!(clauses.subject(), Some("user-42"));
    println!("scope = {}", clauses.app().scope); // -> scope = read:invoices
    Ok(())
}
import obsigil
from obsigil import Obsigil

secret = bytes([42] * 64)

# --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
token = Obsigil.mint(
    clauses={"scope": "read:invoices"},      # sealed, binding mandate clauses
    mandate_key=secret,
    exp=4_000_000_000,                       # REQUIRED absolute expiry
    sub="user-42",                           # optional sub clause
    aud=["invoice-api"],                     # optional aud clause
    manifest={                               # public advisory half
        "iss": "auth.example",     # manifest iss is REQUIRED
        "claims": {"name": "Ada"},
    },
).token()                                    # tid: fresh UUIDv7
print(token)

# --- Front end: read the public manifest, no secret (advisory only) ---
advisory = obsigil.claims(token)             # dict | None — never raises
assert advisory is not None                  # manifest present
assert advisory["iss"] == "auth.example"
assert advisory["name"] == "Ada"

# --- Backend: authenticate the mandate and read its clauses ---
verifier = Obsigil(
    token,
    keys=secret,
    audience="invoice-api",
    now=1_000_000_000,                       # pin "now"; omit to read the system clock
)
clauses = verifier.clauses()                 # checks exp, aud, UUIDv7 tid; one opaque ObsigilError
assert clauses["sub"] == "user-42"
print(f"scope = {clauses['scope']}")         # -> scope = read:invoices
import (
	"fmt"
	"log"

	"obsigil.org/go/obsigil"
)

func main() {
	secret := make([]byte, 64)
	for i := range secret {
		secret[i] = 42
	}

	// --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
	token, err := obsigil.Mint(obsigil.MintInput{
		Clauses:    map[string]any{"scope": "read:invoices"}, // sealed, binding
		MandateKey: secret,
		Exp:        4_000_000_000,           // REQUIRED absolute expiry
		Sub:        "user-42",               // optional sub clause
		Aud:        []string{"invoice-api"}, // optional aud clause
		Manifest: &obsigil.ManifestSpec{ // public advisory half
			Iss:    "auth.example",
			Claims: map[string]any{"name": "Ada"},
		},
		// Tid defaults to a fresh UUIDv7.
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(token)

	// --- Front end: read the public manifest, no secret (advisory only) ---
	advisory, ok := obsigil.Claims(token)
	if !ok {
		log.Fatal("manifest present")
	}
	if iss, _ := obsigil.Iss(advisory); iss != "auth.example" {
		log.Fatalf("issuer = %q", iss)
	}
	_ = advisory["name"] // advisory app claim, "Ada"

	// --- Backend: authenticate the mandate and read its clauses ---
	clauses, err := obsigil.Clauses(token, obsigil.VerifyPolicy{
		Keys:     [][]byte{secret},
		Audience: "invoice-api",
		Now:      1_000_000_000, // pin "now"; 0 reads the system clock
	}) // checks exp, aud, UUIDv7 tid; one opaque *Error on failure
	if err != nil {
		log.Fatal(err)
	}

	if sub, _ := obsigil.Sub(clauses); sub != "user-42" {
		log.Fatalf("subject = %q", sub)
	}
	fmt.Printf("scope = %s\n", clauses["scope"]) // -> scope = read:invoices
}

The token's text encoding is base64 by default. To emit a hex token instead — lowercase base16, joined by the ~ separator — change one line on the issuer; the verifier needs no change, since it recovers the encoding from the separator:

use obsigil::Encoding;

let token = Issuer::new(MandateKey::from_bytes(secret)?)
    .encoding(Encoding::Hex)             // "~" separator instead of "."
    .clauses(&ClauseData { scope: "read:invoices".into() })
    .exp(4_000_000_000)
    .subject("user-42")
    .audience(["invoice-api"])
    .manifest("auth.example", &ClaimData { name: "Ada".into() })
    .mint()?;
// e.g. 2b8c53c5fe30...6e930~0e97f7b68e...f7ee7  (verify exactly as before)

Hex is strictly lowercase (0-9a-f), so case-folding a hex token is lossless: it survives case-insensitive channels — DNS labels, case-normalizing URLs — where base64's significant case would corrupt it. The canonical form is lowercase, and a verifier rejects mixed-case input unless a deployment lowercases it first.

Compared to JWT

Obsigil serves the same shared-secret, bearer-credential niche as a signed JWT, but seals rather than signs — the payload is encrypted, not merely encoded.

JWT (HS256) Obsigil
Payload signed, public (base64url JSON) encrypted & authenticated (AEAD)
Reader split one payload for everyone advisory manifest + enforced mandate
Wire form JSON, JOSE header, 3 dot-parts canonical CBOR, no header, one separator
Determinism not guaranteed byte-identical for identical fields (a unique tid per mandate keeps plaintexts distinct, so no plaintext-equality leak)
Keys shared secret (HS) or key pair (RS/ES) shared secret only (symmetric)

Implementations

Conformance

Obsigil is specified once and verified everywhere: every implementation checks itself against the same language-agnostic test vectors — deterministic sealing means each vector pins the exact token bytes a conformant implementation must produce, and the inputs every implementation must reject.

License

The Obsigil specification and the documentation on this website are licensed under CC BY 4.0; the reference implementation is dual-licensed under the MIT or Apache 2.0 licenses, at your option.