biscuit-cel is a Rust library for Biscuit-style authorization tokens
where the policy language is CEL instead of Datalog. A token
carries a signed chain of blocks: an authority block issued by a root
key, zero or more attenuation blocks, and optional third-party
attestation blocks. Each block contributes typed claims and/or CEL
checks; an authorizer evaluates the token's checks together with
allow/deny policies to decide whether a request is permitted.
The cryptographic chain follows the scheme documented at https://doc.biscuitsec.org/reference/cryptography.html: every block is signed by the previous block's ephemeral key, so holders can attenuate offline without ever seeing the root secret. The attenuation model is the one from the macaroons paper — "cookies with contextual caveats for decentralized authorization" — adapted to a compact, self-contained proto wire format and a modern expression language.
- Full design: docs/design.md
- Wire schema: proto/biscuit_cel.proto
- Distributed authorization. A token is verified offline against a single root public key. No central authorization service is in the request path, and no shared secrets are needed between issuers and verifiers.
- Attenuation. Any holder can append checks to a token without the
root key. The signature chain guarantees that every appended block
is bound to the one before it, so attenuations can only ever narrow
authority — never widen it. Attenuation blocks use a distinct
ChecksOnlyBodyin the proto schema, so the "no claims on attenuation" rule is enforced structurally for any conformant encoder, not just by the Rust API. The chain envelope itself is arepeated Caveatwhose oneof separatesSignedBlock(attenuation) fromExternallySignedBlock(attestation), so the two caveat kinds cannot be confused at decode time either. - CEL checks and policies. Blocks carry CEL expressions; the authorizer carries allow/deny policies. Every expression is parsed and validated at build/attenuation time and stored as an AST, so invalid CEL cannot be serialized and verifiers skip re-parsing on the hot path.
- Compact tokens. The wire format is protobuf. Each chained block
adds a fixed ~96 bytes of crypto overhead (32-byte public key and
64-byte Ed25519 signature) plus the block payload and proto
framing.
ParsedExpr.source_infois stripped from checks by default, gated behind thedebug-check-sourcefeature. A minimal authority-only token (a couple of short claims, one check) fits comfortably inside a single HTTP header. - Third-party blocks (attestation). An external signer — an IdP, a KMS service, another internal service — can attach claims and checks without ever touching the holder's keys. The handshake binds the external signature to the holder's previous block signature, so a signed attestation cannot be replayed onto a different token or chain position.
- Ed25519 by default, backend-agnostic. Ed25519 is the default
algorithm: 32-byte public keys, 64-byte signatures, and fast batch
verification. Crypto flows through the RustCrypto
signaturetraits, so consumers can swap in a different backend (dalek,zebra) or plug in an HSM/KMS signer viaSigner<ed25519::Signature>without forking the library. - Typed claims with unambiguous field names.
ClaimValuevariants use a_valuesuffix (string_value,int_value, …) so there is no overlap between field names and type names, matching thegoogle.protobuf.Valueconvention. - Self-contained and versioned format. A token is a single opaque blob with an explicit block-format version and signature-payload version. No out-of-band metadata is required for verification, and unknown versions are rejected rather than silently accepted.
A biscuit-cel token is a single protobuf blob holding a linked chain of signed blocks plus a terminal proof. Each block's signature covers the previous block's signature, so the chain cannot be reordered, truncated, or spliced without breaking verification. The only asymmetric material a verifier needs is the root public key.
┌──────────────────────────── Biscuit ──────────────────────────────┐
│ root_key_id: Option<u32> │
├───────────────────────────────────────────────────────────────────┤
│ authority : SignedBlock │
│ ├─ Block │
│ │ ├─ version │
│ │ ├─ context? │
│ │ └─ body = FullBody { claims, checks } │
│ ├─ next_key ▶ pk₁ │
│ └─ signature = sign(root_sk, payload₀) │
├───────────────────────────────────────────────────────────────────┤
│ blocks[0] : Caveat = attenuation(SignedBlock) │
│ ├─ Block │
│ │ └─ body = ChecksOnlyBody { checks } ← no `claims` field │
│ ├─ next_key ▶ pk₂ │
│ └─ signature = sign(sk₁, payload₁ ‖ PREVSIG sig₀) │
├───────────────────────────────────────────────────────────────────┤
│ blocks[1] : Caveat = attestation(ExternallySignedBlock) │
│ ├─ Block │
│ │ └─ body = FullBody { claims, checks } │
│ ├─ external = ExternalSignature { │
│ │ signature = sign(tp_sk, │
│ │ EXTERNAL ‖ data ‖ PREVSIG sig₁), │
│ │ public_key = tp_pk } │
│ ├─ next_key ▶ pk₃ │
│ └─ signature = sign(sk₂, │
│ payload₂ ‖ PREVSIG sig₁ ‖ EXTSIG) │
├───────────────────────────────────────────────────────────────────┤
│ Proof │
│ └─ oneof: │
│ next_secret (attenuable — holder can append more) │
│ final_signature (sealed — chain is immutable) │
└───────────────────────────────────────────────────────────────────┘
Key invariants the layout enforces:
- Chain binding.
signature[i]coversPREVSIG = signature[i-1], so tampering with any earlier block invalidates every later one. - Caveat kind is structural.
Biscuit.blocksis arepeated Caveat, andCaveatis a oneof ofSignedBlock(attenuation) andExternallySignedBlock(attestation). A decoder cannot mis-classify one as the other, regardless of field presence. - Body shape is structural. Authority and attestation caveats
carry
FullBody; attenuation caveats carryChecksOnlyBody, which has noclaimsfield in the proto. Any conformant encoder — in any language — cannot attach claims to an attenuation block. - External signatures are embedded. The external signature is
mandatory on
ExternallySignedBlock, and the enclosing block's signature payload covers it too, so an attacker cannot strip or swap a third-party attestation. - Proof is terminal.
next_secretpermits more attenuation;final_signatureseals the token and makes any further append fail closed.
Third-party blocks let an external signer — an IdP, a KMS service — attach claims and checks without ever seeing the holder's keys, and without the holder ever seeing the signer's keys. The handshake binds the resulting signature to a specific chain position so the attestation cannot be replayed onto a different token.
Holder (token owner) Third-party signer (e.g. IdP)
┌──────────────────┐ ┌──────────────────────────┐
│ current token │ │ own keypair (tp_sk,tp_pk)│
│ last sig = sig_n │ │ claims to assert │
└────────┬─────────┘ └──────────────┬────────────┘
│ │
│ 1. ThirdPartyRequest { │
│ previous_signature = sig_n } │
│ ──────────────────────────────────────────► │
│ │
│ 2. build Block bytes `data`:
│ FullBody { claims, checks }
│ external_sig = sign(tp_sk,
│ "EXTERNAL" ‖ data ‖ sig_n)
│ │
│ 3. SignedThirdPartyBlock { │
│ block = data, │
│ external_signature = (sig, tp_pk) } │
│ ◄────────────────────────────────────────── │
│
│ 4. append_third_party(signed):
│ a. verify external_sig under tp_pk
│ b. verify PREVSIG == sig_n ← replay protection
│ c. sign enclosing SignedBlock with the current
│ next_secret, embedding external_sig in the payload
│ d. rotate ephemeral key (new next_secret)
▼
┌──────────────────┐
│ new token │
│ chain length + 1 │
│ last sig = sig_{n+1}
└──────────────────┘
No key material crosses either wire boundary. The verifier later
decides whether to trust the attestation by pinning tp_pk inside a
CEL policy on third_party[i].public_key — see
CEL Authorization Context.
Block, external, and seal signatures are computed over a
tag-delimited byte concatenation, not a proto or JSON encoding.
Every field is preceded by a NUL-framed ASCII tag (\0BLOCK\0,
\0PAYLOAD\0, \0PREVSIG\0, …) and integers are little-endian
fixed-width. See src/signature.rs; the exact
bytes are pinned by a golden-vector test in
tests/integration.rs.
Why not proto or JSON:
- Canonical by construction. A signature is bytes-exact. Proto3 does not promise a canonical encoding (field order, default omission, packed repeats are implementation-defined), and JSON is worse (key order, whitespace, Unicode normalization, float formatting). The tagged concatenation has exactly one valid form, so signer and verifier always agree.
- Domain separation. Each payload type opens with a distinct
marker (
BLOCK/EXTERNAL/SEAL). A signature over one kind can never be replayed as another, regardless of field contents. - Native binary, no bloat. Keys and signatures go in as raw bytes — no base64, no hex. The signer hashes ~the minimum possible number of bytes.
- Trivial cross-language reimplementation. A non-Rust verifier needs ~20 lines and no proto/JSON library to reproduce the payload. The golden-vector test doubles as the interop spec.
When CelAuthorizer::authorize evaluates a token's checks and its own
policies, it exposes four top-level variables to every CEL expression.
Policies and checks see the same context.
| Variable | CEL type | Source |
|---|---|---|
claims |
map<string, CelValue> |
Authority block claims |
request |
map<string, CelValue> |
Authorizer-supplied context |
now |
timestamp |
Wall clock at authorize() time |
third_party |
list<map> |
Third-party blocks, in chain order |
The claims attached to the authority block — the block signed by
the root key. Third-party block claims are not merged here; they
live under third_party[i].claims. Keys are the strings passed to
TokenBuilder::claim; values are the typed CelValues that were
stored (Int, String, Bool, Bytes, Timestamp, Null, List,
Map). Missing keys evaluate as unset — guard with has() when a
claim is optional.
claims.org == 'acme' && claims.user == 'alice'
has(claims.roles) && 'admin' in claims.roles
The per-authorization context the verifier supplies via
CelAuthorizer::context(key, value). This is where request-scoped
attributes belong — HTTP method, resource path, caller IP, the
resource being accessed — anything the token itself cannot carry
because it is not known at issuance time. The map is empty if the
verifier did not add any entries.
request.method == 'GET' && request.path.startsWith('/v1/')
request.actor_id == claims.user
A CEL timestamp captured at the moment authorize() runs, using the
system wall clock in UTC. Use it to enforce time-bounded attenuation
checks or policy windows.
now < timestamp('2026-12-31T00:00:00Z') // token expiry
now - claims.issued_at < duration('1h') // freshness
A list of records, one per third-party block in chain order
(third_party[0] is the first third-party block appended, and so on).
Each record exposes the signer's raw public key plus the claims that
signer attached:
third_party[i] = {
public_key: bytes, // raw 32-byte Ed25519 public key
claims: map<string, dyn>, // claims the third party attached
}
public_keyis raw bytes, not hex. Compare against a byte literal (b'\x12\x34…') so the policy does not depend on a canonical hex encoding.- The list is indexed by block order, not by key. A signer can issue multiple blocks; a list preserves that multiplicity, a map keyed by public key would hide it.
- Always gate on the public key before trusting attached claims — otherwise any third party could forge the attribute.
size(third_party) > 0
&& third_party[0].public_key == b'\x12\x34…'
&& third_party[0].claims.email_verified == true
Checks attached to blocks run with this same context, so an attenuation block can, for example, require that the request hits a specific path:
// Attached as a check on an attenuation block.
request.path.startsWith('/v1/reports/') && request.method == 'GET'
A service issues a token for alice, attenuates it to read-only, and a
verifier authorizes a read request.
use biscuit_cel::{Algorithm, BiscuitCel, CelToken, KeyPair, PolicyKind};
let root = KeyPair::new(Algorithm::Ed25519);
// Issuer: mint an authority token.
let token = BiscuitCel::builder()
.claim("user", "alice")
.claim("org", "acme")
.check("claims.user == 'alice'")?
.build(&root)?;
// Holder: attenuate to read-only before handing the token to a client.
let token = token
.attenuate()
.check("request.action == 'read'")?
.build()?;
// Wire transport.
let wire = token.to_base64()?;
// Verifier: decode against the known root public key and authorize.
let token = CelToken::from_base64(&wire, &root.public())?;
let idx = BiscuitCel::authorizer()
.context("action", "read")
.policy(
PolicyKind::Allow,
"claims.org == 'acme' && request.action == 'read'",
)?
.policy(PolicyKind::Deny, "true")?
.authorize(&token)?;
assert_eq!(idx, 0); // the allow policy matched
# Ok::<(), biscuit_cel::Error>(())The holder asks an identity provider to attest email_verified, then
the verifier gates on a specific IdP public key plus the attested
claim.
use biscuit_cel::{
Algorithm, BiscuitCel, KeyPair, PolicyKind,
SignedThirdPartyBlock, ThirdPartyRequest,
};
let root = KeyPair::new(Algorithm::Ed25519);
let idp = KeyPair::new(Algorithm::Ed25519); // third-party signer
// 1. Holder issues its authority token.
let token = BiscuitCel::builder()
.claim("user", "alice")
.claim("org", "acme")
.build(&root)?;
// 2. Holder builds a request bound to its current chain tail.
let req_bytes = token.third_party_request().to_vec()?;
// --- wire boundary: holder → IdP ---
// 3. IdP receives the request, attaches claims + checks, signs with
// its own keypair. Neither party sees the other's secret material.
let req = ThirdPartyRequest::from_bytes(&req_bytes)?;
let signed = BiscuitCel::third_party_block(req)
.claim("email", "alice@acme.example")
.claim("email_verified", true)
.check("claims.org == 'acme'")? // IdP insists on the org
.sign(&idp)?;
let signed_bytes = signed.to_vec()?;
// --- wire boundary: IdP → holder ---
// 4. Holder appends the signed third-party block to its token.
let signed = SignedThirdPartyBlock::from_bytes(&signed_bytes)?;
let token = token.append_third_party(signed)?;
// 5. Verifier gates on the IdP's public key and the attested claim.
let idp_pk = idp.public().as_bytes().to_vec();
let policy = format!(
"size(third_party) > 0 \
&& third_party[0].public_key == b'{}' \
&& third_party[0].claims.email_verified == true",
idp_pk.iter().map(|b| format!("\\x{b:02x}")).collect::<String>(),
);
let idx = BiscuitCel::authorizer()
.policy(PolicyKind::Allow, &policy)?
.policy(PolicyKind::Deny, "true")?
.authorize(&token)?;
assert_eq!(idx, 0);
# Ok::<(), biscuit_cel::Error>(())Replay and tampering fail closed: appending a SignedThirdPartyBlock
to a different token, flipping a byte of the block payload, or
stripping the external signature all cause verification to reject the
token.
- Rust stable (edition 2021). No system
protocis required — the build usesprotoxto compileproto/biscuit_cel.protointo Rust viaprost-build.
cargo build # default backend (dalek)
cargo build --no-default-features \
--features backend-zebra # swap crypto backend
cargo build --features debug-check-source # preserve CEL source_infoThe generated prost module lands in $OUT_DIR/biscuit_cel.v1.rs and
is included from src/schema.rs. Editing the proto
file triggers a rebuild automatically (cargo:rerun-if-changed).
cargo test # unit + integration
cargo test --features debug-check-source # feature-gated pathsIntegration tests live in tests/integration.rs
and exercise the public API end-to-end — builder, attenuation,
serialization, third-party handshake, replay protection, and CEL
authorization. They do not mock the crypto or the wire format.
cargo clippy --all-targets --all-features
cargo fmt --all -- --checkClippy must be clean on changes you contribute. Run cargo fmt before
pushing.
Fuzz targets live in a sibling crate under fuzz/ so nightly
and libfuzzer-sys never leak into the main crate's build graph.
cargo-fuzz is nightly-only.
cargo install cargo-fuzz
rustup toolchain install nightlyThree targets cover the untrusted-input surface:
| Target | What it exercises |
|---|---|
block_decode |
BlockBody::decode — protobuf + AST validation |
biscuit_from_bytes |
Full token parse + signature-verify chain |
biscuit_roundtrip |
BlockBody encode → decode → encode is a fixed point |
Run a target (Ctrl-C to stop; the corpus persists under
fuzz/corpus/<target>/):
cargo +nightly fuzz run block_decode
cargo +nightly fuzz run biscuit_from_bytes
cargo +nightly fuzz run biscuit_roundtripTime-boxed smoke run (handy before a PR):
cargo +nightly fuzz run block_decode -- -max_total_time=60Reproduce a crash from an artifact:
cargo +nightly fuzz run block_decode fuzz/artifacts/block_decode/crash-*Just compile the targets without fuzzing — fast API-drift check:
cd fuzz && cargo +nightly build --releaseCoverage is not wired into CI yet. Locally, use
cargo-llvm-cov:
cargo install cargo-llvm-cov
cargo llvm-cov --all-features --html # browse at target/llvm-cov/html
cargo llvm-cov --all-features --summary-onlybiscuit-cel/
├── build.rs # prost-build + protox code generation
├── proto/biscuit_cel.proto # wire schema (source of truth)
├── src/
│ ├── lib.rs # crate facade — BiscuitCel, re-exports
│ ├── schema.rs # includes generated prost types
│ ├── crypto/ # Algorithm, KeyPair, pluggable backends
│ ├── signature.rs # payload v1 tagged concatenation
│ ├── ast.rs # CEL parse / validate / execute
│ ├── value.rs # CelValue ↔ ClaimValue ↔ cel::Value
│ ├── block.rs # Block body encode/decode
│ ├── token.rs # CelToken, chain verification
│ ├── builder.rs # TokenBuilder, AttenuationBuilder
│ ├── third_party.rs # third-party handshake types
│ ├── authorizer.rs # CelAuthorizer + policies
│ └── error.rs
├── tests/integration.rs # end-to-end tests
├── fuzz/ # cargo-fuzz targets (nightly, out of tree)
└── docs/design.md # design rationale