Skip to main content

assay_auth/
biscuit.rs

1//! Biscuit capability tokens — public-key signed bearers with
2//! Datalog policy, offline verification, and caller-side attenuation.
3//!
4//! Plan 11 reference: `biscuit-auth` 6 — public-key signed, Datalog
5//! policy, offline verifiable, attenuable. Phase 5 + correction (per
6//! coordinator): biscuit is foundational, NOT feature-gated, and ships
7//! in every build that pulls assay-auth in.
8//!
9//! Lifecycle:
10//!
11//! 1. Boot loads the active root keypair from `auth.biscuit_root_keys`
12//!    (the row with `rotated_at IS NULL`); if no row exists, generates
13//!    a fresh Ed25519 keypair and INSERTs it. Mirrors the JWKS bootstrap
14//!    pattern in [`crate::jwt`].
15//! 2. [`BiscuitConfig::issue`] signs a fresh authority block via the
16//!    builder closure passed by the caller, returning a base64-encoded
17//!    URL-safe token ready for `Authorization: Bearer …`.
18//! 3. [`BiscuitConfig::verify`] base64-decodes, validates the signature
19//!    against the cached root public key, and runs a caller-supplied
20//!    [`Authorizer`] (policies + checks).
21//! 4. [`BiscuitConfig::attenuate`] is a free function — caller-side, no
22//!    root key required. Appends a more-restrictive block and
23//!    re-encodes. The result is a valid biscuit that any verifier with
24//!    the same root public key can validate without re-contacting
25//!    assay-engine.
26
27use std::sync::Arc;
28
29use biscuit_auth::builder::AuthorizerBuilder;
30use biscuit_auth::{Biscuit, BiscuitBuilder, BlockBuilder, KeyPair, PublicKey};
31use parking_lot::RwLock;
32
33use crate::error::{Error, Result};
34
35/// Active root key + history. The active row signs new tokens; history
36/// rows still verify previously-issued tokens until they expire on
37/// their own.
38struct Inner {
39    active: ActiveRootKey,
40    history: Vec<HistoryRootKey>,
41}
42
43/// Active root key — signs new biscuits, also verifies them.
44pub struct ActiveRootKey {
45    pub kid: String,
46    pub keypair: KeyPair,
47}
48
49/// Verify-only entry — older root keys retained so previously-issued
50/// biscuits validate after a rotation. We don't track a `kid` per
51/// biscuit yet (biscuit-auth 6 carries an optional `root_key_id`); for
52/// now we attempt the active key first then fall through history.
53pub struct HistoryRootKey {
54    pub kid: String,
55    pub public_key: PublicKey,
56}
57
58/// Cheap-to-clone biscuit configuration. Wraps the active keypair +
59/// history behind an [`RwLock`] so a future `rotate` lands without
60/// breaking inflight callers.
61#[derive(Clone)]
62pub struct BiscuitConfig {
63    inner: Arc<RwLock<Inner>>,
64}
65
66impl BiscuitConfig {
67    /// Construct from an explicit active root keypair. Useful for tests
68    /// and for engine boot's "load row, build config" path.
69    pub fn from_active(active: ActiveRootKey, history: Vec<HistoryRootKey>) -> Self {
70        Self {
71            inner: Arc::new(RwLock::new(Inner { active, history })),
72        }
73    }
74
75    /// Generate a fresh ephemeral Ed25519 root keypair without touching
76    /// any DB. The default for [`crate::ctx::AuthCtx::new`] callers
77    /// that don't have a persistent root key yet — engine boot replaces
78    /// this with the loaded-or-generated row via
79    /// [`crate::ctx::AuthCtx::with_biscuit`].
80    pub fn generate_ephemeral() -> Self {
81        let keypair = KeyPair::new();
82        let kid = mint_kid(&keypair.public());
83        Self::from_active(ActiveRootKey { kid, keypair }, Vec::new())
84    }
85
86    /// Construct from an existing root keypair PEM (the format
87    /// [`KeyPair::to_private_key_pem`] emits). Used by engine boot
88    /// when the `auth.biscuit_root_keys` row carries a stored private
89    /// key.
90    pub fn from_pem(pem: &str) -> Result<Self> {
91        let keypair = KeyPair::from_private_key_pem(pem)
92            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit root key from pem: {e}")))?;
93        let kid = mint_kid(&keypair.public());
94        Ok(Self::from_active(
95            ActiveRootKey { kid, keypair },
96            Vec::new(),
97        ))
98    }
99
100    /// Borrow the active root key id (kid). Cheap; clones one short
101    /// string under the read lock.
102    pub fn active_kid(&self) -> String {
103        self.inner.read().active.kid.clone()
104    }
105
106    /// Render the active root public key as a PEM string for
107    /// distribution to standalone verifiers (mobile clients, edge
108    /// services). Stable as long as the active row in
109    /// `auth.biscuit_root_keys` doesn't rotate.
110    pub fn public_pem(&self) -> Result<String> {
111        self.inner
112            .read()
113            .active
114            .keypair
115            .public()
116            .to_pem()
117            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit public pem: {e}")))
118    }
119
120    /// Borrow the active root public key. Useful for test
121    /// reconstruction and for the public_pem helper.
122    pub fn active_public_key(&self) -> PublicKey {
123        self.inner.read().active.keypair.public()
124    }
125
126    /// Issue a fresh biscuit via the supplied builder closure. The
127    /// closure receives an empty [`BiscuitBuilder`] and returns the
128    /// completed builder; we sign + base64-URL-encode it for the wire.
129    ///
130    /// Example:
131    /// ```ignore
132    /// let token = cfg.issue(|b| b.fact("user(\"alice\")"))?;
133    /// ```
134    pub fn issue<F>(&self, build: F) -> Result<String>
135    where
136        F: FnOnce(
137            BiscuitBuilder,
138        ) -> std::result::Result<BiscuitBuilder, biscuit_auth::error::Token>,
139    {
140        let builder = build(Biscuit::builder())
141            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit build: {e}")))?;
142        let guard = self.inner.read();
143        let token = builder
144            .build(&guard.active.keypair)
145            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit sign: {e}")))?;
146        token
147            .to_base64()
148            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit base64: {e}")))
149    }
150
151    /// Verify a biscuit and run the supplied authorizer against it. The
152    /// closure receives a fresh [`AuthorizerBuilder`]; add policies +
153    /// checks via its builder methods, returning the completed builder.
154    /// We then build the authorizer against the parsed token and call
155    /// `authorize`.
156    ///
157    /// `Ok(())` means the token was syntactically valid, signed by a
158    /// known root key, and matched at least one allow policy without
159    /// triggering any deny / failed check.
160    pub fn verify<F>(&self, token: &str, build: F) -> Result<()>
161    where
162        F: FnOnce(
163            AuthorizerBuilder,
164        ) -> std::result::Result<AuthorizerBuilder, biscuit_auth::error::Token>,
165    {
166        let guard = self.inner.read();
167        // Try the active key first; on signature mismatch, fall through
168        // to history (each history row is a previously-active root).
169        let parsed = match Biscuit::from_base64(token, guard.active.keypair.public()) {
170            Ok(t) => t,
171            Err(active_err) => {
172                let mut last = active_err;
173                let mut found = None;
174                for hist in &guard.history {
175                    match Biscuit::from_base64(token, hist.public_key) {
176                        Ok(t) => {
177                            found = Some(t);
178                            break;
179                        }
180                        Err(e) => last = e,
181                    }
182                }
183                match found {
184                    Some(t) => t,
185                    None => {
186                        return Err(Error::Backend(anyhow::anyhow!(
187                            "biscuit signature verify: {last}"
188                        )));
189                    }
190                }
191            }
192        };
193        let authorizer_builder = build(AuthorizerBuilder::new())
194            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit authorizer build: {e}")))?;
195        let mut authorizer = authorizer_builder
196            .build(&parsed)
197            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit authorizer attach: {e}")))?;
198        authorizer
199            .authorize()
200            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit authorize: {e}")))?;
201        Ok(())
202    }
203}
204
205/// Caller-side attenuation. Anyone with the bearer token (and no
206/// access to the root keypair) can append a new block of restrictions.
207/// The result is a valid biscuit that the original verifier accepts.
208///
209/// `root_public` is needed to parse the source token; for assay's
210/// in-process callers this is [`BiscuitConfig::active_public_key`].
211/// Standalone clients pass the PEM-loaded [`PublicKey`] from
212/// distribution.
213pub fn attenuate<F>(token: &str, root_public: PublicKey, build: F) -> Result<String>
214where
215    F: FnOnce(BlockBuilder) -> std::result::Result<BlockBuilder, biscuit_auth::error::Token>,
216{
217    let parsed = Biscuit::from_base64(token, root_public)
218        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit parse for attenuate: {e}")))?;
219    let block = build(BlockBuilder::new())
220        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit block build: {e}")))?;
221    let attenuated = parsed
222        .append(block)
223        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit append block: {e}")))?;
224    attenuated
225        .to_base64()
226        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit base64 (attenuated): {e}")))
227}
228
229/// Postgres bootstrap helper. Loads the active root key from
230/// `auth.biscuit_root_keys`; if no row exists, generates a fresh
231/// Ed25519 keypair, persists it, and returns a config that uses it.
232///
233/// Called from engine boot. Mirrors the JWKS bootstrap pattern in
234/// [`crate::jwt::JwtConfig::load_from_postgres`] / `rotate_postgres`.
235#[cfg(feature = "backend-postgres")]
236pub async fn load_or_init_postgres(pool: &sqlx::PgPool) -> Result<BiscuitConfig> {
237    use sqlx::Row;
238    let row = sqlx::query(
239        "SELECT kid, private_pem
240         FROM auth.biscuit_root_keys
241         WHERE rotated_at IS NULL
242         ORDER BY created_at DESC
243         LIMIT 1",
244    )
245    .fetch_optional(pool)
246    .await
247    .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.biscuit_root_keys (pg): {e}")))?;
248
249    if let Some(row) = row {
250        let kid: String = row.get("kid");
251        let pem_bytes: Vec<u8> = row.get("private_pem");
252        let pem = std::str::from_utf8(&pem_bytes)
253            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit private_pem utf8: {e}")))?;
254        let keypair = KeyPair::from_private_key_pem(pem)
255            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit from_private_key_pem: {e}")))?;
256        Ok(BiscuitConfig::from_active(
257            ActiveRootKey { kid, keypair },
258            Vec::new(),
259        ))
260    } else {
261        let keypair = KeyPair::new();
262        let kid = mint_kid(&keypair.public());
263        let private_pem = keypair
264            .to_private_key_pem()
265            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit to_private_key_pem: {e}")))?
266            .to_string();
267        let public_pem = keypair
268            .public()
269            .to_pem()
270            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit public to_pem: {e}")))?;
271        let now = now_secs();
272        sqlx::query(
273            "INSERT INTO auth.biscuit_root_keys
274                 (kid, private_pem, public_pem, created_at, rotated_at)
275             VALUES ($1, $2, $3, $4, NULL)",
276        )
277        .bind(&kid)
278        .bind(private_pem.as_bytes())
279        .bind(&public_pem)
280        .bind(now)
281        .execute(pool)
282        .await
283        .map_err(|e| Error::Backend(anyhow::anyhow!("insert auth.biscuit_root_keys (pg): {e}")))?;
284        Ok(BiscuitConfig::from_active(
285            ActiveRootKey { kid, keypair },
286            Vec::new(),
287        ))
288    }
289}
290
291/// SQLite mirror of [`load_or_init_postgres`].
292#[cfg(feature = "backend-sqlite")]
293pub async fn load_or_init_sqlite(pool: &sqlx::SqlitePool) -> Result<BiscuitConfig> {
294    use sqlx::Row;
295    let row = sqlx::query(
296        "SELECT kid, private_pem
297         FROM auth.biscuit_root_keys
298         WHERE rotated_at IS NULL
299         ORDER BY created_at DESC
300         LIMIT 1",
301    )
302    .fetch_optional(pool)
303    .await
304    .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.biscuit_root_keys (sqlite): {e}")))?;
305
306    if let Some(row) = row {
307        let kid: String = row.get("kid");
308        let pem_bytes: Vec<u8> = row.get("private_pem");
309        let pem = std::str::from_utf8(&pem_bytes)
310            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit private_pem utf8: {e}")))?;
311        let keypair = KeyPair::from_private_key_pem(pem)
312            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit from_private_key_pem: {e}")))?;
313        Ok(BiscuitConfig::from_active(
314            ActiveRootKey { kid, keypair },
315            Vec::new(),
316        ))
317    } else {
318        let keypair = KeyPair::new();
319        let kid = mint_kid(&keypair.public());
320        let private_pem = keypair
321            .to_private_key_pem()
322            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit to_private_key_pem: {e}")))?
323            .to_string();
324        let public_pem = keypair
325            .public()
326            .to_pem()
327            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit public to_pem: {e}")))?;
328        let now = now_secs();
329        sqlx::query(
330            "INSERT INTO auth.biscuit_root_keys
331                 (kid, private_pem, public_pem, created_at, rotated_at)
332             VALUES (?, ?, ?, ?, NULL)",
333        )
334        .bind(&kid)
335        .bind(private_pem.as_bytes())
336        .bind(&public_pem)
337        .bind(now)
338        .execute(pool)
339        .await
340        .map_err(|e| {
341            Error::Backend(anyhow::anyhow!(
342                "insert auth.biscuit_root_keys (sqlite): {e}"
343            ))
344        })?;
345        Ok(BiscuitConfig::from_active(
346            ActiveRootKey { kid, keypair },
347            Vec::new(),
348        ))
349    }
350}
351
352/// Mint a deterministic-ish kid from the public key bytes — short
353/// base64 prefix so two distinct keypairs collide with negligible
354/// probability and operators can eyeball-correlate rows. Stable: same
355/// input bytes yield the same kid.
356fn mint_kid(public_key: &PublicKey) -> String {
357    let bytes = public_key.to_bytes();
358    let prefix = if bytes.len() >= 16 {
359        &bytes[..16]
360    } else {
361        &bytes
362    };
363    format!("kid_{}", data_encoding::BASE64URL_NOPAD.encode(prefix))
364}
365
366fn now_secs() -> f64 {
367    std::time::SystemTime::now()
368        .duration_since(std::time::UNIX_EPOCH)
369        .unwrap_or_default()
370        .as_secs_f64()
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn cfg() -> BiscuitConfig {
378        BiscuitConfig::generate_ephemeral()
379    }
380
381    #[test]
382    fn issue_and_verify_round_trip() {
383        let cfg = cfg();
384        let token = cfg
385            .issue(|b| {
386                b.fact("user(\"alice\")")
387                    .and_then(|b| b.fact("role(\"admin\")"))
388            })
389            .expect("issue");
390        cfg.verify(&token, |a| a.policy("allow if user(\"alice\")"))
391            .expect("verify");
392    }
393
394    #[test]
395    fn verify_rejects_tampered_token() {
396        let cfg = cfg();
397        let token = cfg.issue(|b| b.fact("user(\"alice\")")).expect("issue");
398        // Flip a single byte mid-token; the signature verify should fail.
399        let mut bytes = token.into_bytes();
400        let half = bytes.len() / 2;
401        bytes[half] ^= 0x01;
402        let tampered = String::from_utf8_lossy(&bytes).to_string();
403        let result = cfg.verify(&tampered, |a| a.policy("allow if user(\"alice\")"));
404        assert!(matches!(result, Err(Error::Backend(_))));
405    }
406
407    #[test]
408    fn verify_with_unknown_root_key_fails() {
409        // Issue with cfg_a, verify with cfg_b — different root keypair.
410        let cfg_a = cfg();
411        let cfg_b = cfg();
412        let token = cfg_a.issue(|b| b.fact("user(\"alice\")")).expect("issue");
413        let result = cfg_b.verify(&token, |a| a.policy("allow if user(\"alice\")"));
414        assert!(matches!(result, Err(Error::Backend(_))));
415    }
416
417    #[test]
418    fn attenuate_produces_valid_child_token() {
419        let cfg = cfg();
420        let token = cfg.issue(|b| b.fact("user(\"alice\")")).expect("issue");
421        let pubkey = cfg.active_public_key();
422        // Attenuate with an extra fact + a check: reading must be
423        // explicitly allowed.
424        let attenuated = attenuate(&token, pubkey, |b| b.check("check if operation(\"read\")"))
425            .expect("attenuate");
426        // With operation("read") → allowed.
427        cfg.verify(&attenuated, |a| {
428            a.fact("operation(\"read\")")
429                .and_then(|a| a.policy("allow if user(\"alice\")"))
430        })
431        .expect("read should pass");
432        // Without operation("read") → check fails (no fact => check
433        // unsatisfied), authorize errors.
434        let result = cfg.verify(&attenuated, |a| a.policy("allow if user(\"alice\")"));
435        assert!(matches!(result, Err(Error::Backend(_))));
436    }
437
438    #[test]
439    fn time_based_check_rejects_after_expiry() {
440        let cfg = cfg();
441        // Issue an unrestricted token; the attenuation pins a time check.
442        let token = cfg.issue(|b| b.fact("user(\"alice\")")).expect("issue");
443        let pubkey = cfg.active_public_key();
444        let attenuated = attenuate(&token, pubkey, |b| {
445            // Note: time/2026 in past is rejected; pin to year 2000 so
446            // any "now" the test runs in is past expiry.
447            b.check("check if time($now), $now < 2000-01-01T00:00:00Z")
448        })
449        .expect("attenuate");
450        let result = cfg.verify(&attenuated, |a| a.time().policy("allow if user(\"alice\")"));
451        assert!(matches!(result, Err(Error::Backend(_))));
452    }
453
454    #[test]
455    fn from_pem_round_trips() {
456        let cfg = cfg();
457        let pem = cfg
458            .inner
459            .read()
460            .active
461            .keypair
462            .to_private_key_pem()
463            .expect("to_private_key_pem")
464            .to_string();
465        let restored = BiscuitConfig::from_pem(&pem).expect("from_pem");
466        // The restored keypair should produce the same public key.
467        assert_eq!(
468            restored.active_public_key().to_bytes(),
469            cfg.active_public_key().to_bytes(),
470        );
471    }
472
473    #[test]
474    fn public_pem_is_non_empty_and_pem_shaped() {
475        let cfg = cfg();
476        let pem = cfg.public_pem().expect("public_pem");
477        assert!(pem.contains("PUBLIC KEY"), "got: {pem}");
478    }
479
480    #[test]
481    fn active_kid_is_stable_across_clones() {
482        let cfg = cfg();
483        let kid = cfg.active_kid();
484        let dup = cfg.clone();
485        assert_eq!(kid, dup.active_kid());
486    }
487}