Skip to content

ccojocar/biscuit-cel

Repository files navigation

biscuit-cel

Overview

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.

Core Principles

  1. 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.
  2. 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 ChecksOnlyBody in 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 a repeated Caveat whose oneof separates SignedBlock (attenuation) from ExternallySignedBlock (attestation), so the two caveat kinds cannot be confused at decode time either.
  3. 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.
  4. 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_info is stripped from checks by default, gated behind the debug-check-source feature. A minimal authority-only token (a couple of short claims, one check) fits comfortably inside a single HTTP header.
  5. 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.
  6. 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 signature traits, so consumers can swap in a different backend (dalek, zebra) or plug in an HSM/KMS signer via Signer<ed25519::Signature> without forking the library.
  7. Typed claims with unambiguous field names. ClaimValue variants use a _value suffix (string_value, int_value, …) so there is no overlap between field names and type names, matching the google.protobuf.Value convention.
  8. 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.

Architecture

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.

Token structure

┌──────────────────────────── 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] covers PREVSIG = signature[i-1], so tampering with any earlier block invalidates every later one.
  • Caveat kind is structural. Biscuit.blocks is a repeated Caveat, and Caveat is a oneof of SignedBlock (attenuation) and ExternallySignedBlock (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 carry ChecksOnlyBody, which has no claims field 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_secret permits more attenuation; final_signature seals the token and makes any further append fail closed.

Third-party attestation handshake

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.

Signed Payload Format

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.

CEL Authorization Context

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

claims

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

request

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

now

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

third_party

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_key is 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'

Examples

1. Authority-only token

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>(())

2. Third-party attestation

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.

Development

Prerequisites

  • Rust stable (edition 2021). No system protoc is required — the build uses protox to compile proto/biscuit_cel.proto into Rust via prost-build.

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_info

The 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).

Test

cargo test                               # unit + integration
cargo test --features debug-check-source # feature-gated paths

Integration 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.

Lint

cargo clippy --all-targets --all-features
cargo fmt --all -- --check

Clippy must be clean on changes you contribute. Run cargo fmt before pushing.

Fuzz

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 nightly

Three 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_roundtrip

Time-boxed smoke run (handy before a PR):

cargo +nightly fuzz run block_decode -- -max_total_time=60

Reproduce 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 --release

Coverage

Coverage 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-only

Repository layout

biscuit-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

About

Distributed authorization token for Rust with CEL expressions for checks and policies

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages