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(BiscuitBuilder) -> std::result::Result<BiscuitBuilder, biscuit_auth::error::Token>,
137    {
138        let builder = build(Biscuit::builder())
139            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit build: {e}")))?;
140        let guard = self.inner.read();
141        let token = builder
142            .build(&guard.active.keypair)
143            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit sign: {e}")))?;
144        token
145            .to_base64()
146            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit base64: {e}")))
147    }
148
149    /// Verify a biscuit and run the supplied authorizer against it. The
150    /// closure receives a fresh [`AuthorizerBuilder`]; add policies +
151    /// checks via its builder methods, returning the completed builder.
152    /// We then build the authorizer against the parsed token and call
153    /// `authorize`.
154    ///
155    /// `Ok(())` means the token was syntactically valid, signed by a
156    /// known root key, and matched at least one allow policy without
157    /// triggering any deny / failed check.
158    pub fn verify<F>(&self, token: &str, build: F) -> Result<()>
159    where
160        F: FnOnce(AuthorizerBuilder) -> std::result::Result<AuthorizerBuilder, biscuit_auth::error::Token>,
161    {
162        let guard = self.inner.read();
163        // Try the active key first; on signature mismatch, fall through
164        // to history (each history row is a previously-active root).
165        let parsed = match Biscuit::from_base64(token, guard.active.keypair.public()) {
166            Ok(t) => t,
167            Err(active_err) => {
168                let mut last = active_err;
169                let mut found = None;
170                for hist in &guard.history {
171                    match Biscuit::from_base64(token, hist.public_key) {
172                        Ok(t) => {
173                            found = Some(t);
174                            break;
175                        }
176                        Err(e) => last = e,
177                    }
178                }
179                match found {
180                    Some(t) => t,
181                    None => {
182                        return Err(Error::Backend(anyhow::anyhow!(
183                            "biscuit signature verify: {last}"
184                        )))
185                    }
186                }
187            }
188        };
189        let authorizer_builder = build(AuthorizerBuilder::new())
190            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit authorizer build: {e}")))?;
191        let mut authorizer = authorizer_builder
192            .build(&parsed)
193            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit authorizer attach: {e}")))?;
194        authorizer
195            .authorize()
196            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit authorize: {e}")))?;
197        Ok(())
198    }
199}
200
201/// Caller-side attenuation. Anyone with the bearer token (and no
202/// access to the root keypair) can append a new block of restrictions.
203/// The result is a valid biscuit that the original verifier accepts.
204///
205/// `root_public` is needed to parse the source token; for assay's
206/// in-process callers this is [`BiscuitConfig::active_public_key`].
207/// Standalone clients pass the PEM-loaded [`PublicKey`] from
208/// distribution.
209pub fn attenuate<F>(token: &str, root_public: PublicKey, build: F) -> Result<String>
210where
211    F: FnOnce(BlockBuilder) -> std::result::Result<BlockBuilder, biscuit_auth::error::Token>,
212{
213    let parsed = Biscuit::from_base64(token, root_public)
214        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit parse for attenuate: {e}")))?;
215    let block = build(BlockBuilder::new())
216        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit block build: {e}")))?;
217    let attenuated = parsed
218        .append(block)
219        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit append block: {e}")))?;
220    attenuated
221        .to_base64()
222        .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit base64 (attenuated): {e}")))
223}
224
225/// Postgres bootstrap helper. Loads the active root key from
226/// `auth.biscuit_root_keys`; if no row exists, generates a fresh
227/// Ed25519 keypair, persists it, and returns a config that uses it.
228///
229/// Called from engine boot. Mirrors the JWKS bootstrap pattern in
230/// [`crate::jwt::JwtConfig::load_from_postgres`] / `rotate_postgres`.
231#[cfg(feature = "backend-postgres")]
232pub async fn load_or_init_postgres(pool: &sqlx::PgPool) -> Result<BiscuitConfig> {
233    use sqlx::Row;
234    let row = sqlx::query(
235        "SELECT kid, private_pem
236         FROM auth.biscuit_root_keys
237         WHERE rotated_at IS NULL
238         ORDER BY created_at DESC
239         LIMIT 1",
240    )
241    .fetch_optional(pool)
242    .await
243    .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.biscuit_root_keys (pg): {e}")))?;
244
245    if let Some(row) = row {
246        let kid: String = row.get("kid");
247        let pem_bytes: Vec<u8> = row.get("private_pem");
248        let pem = std::str::from_utf8(&pem_bytes)
249            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit private_pem utf8: {e}")))?;
250        let keypair = KeyPair::from_private_key_pem(pem)
251            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit from_private_key_pem: {e}")))?;
252        Ok(BiscuitConfig::from_active(
253            ActiveRootKey { kid, keypair },
254            Vec::new(),
255        ))
256    } else {
257        let keypair = KeyPair::new();
258        let kid = mint_kid(&keypair.public());
259        let private_pem = keypair
260            .to_private_key_pem()
261            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit to_private_key_pem: {e}")))?
262            .to_string();
263        let public_pem = keypair
264            .public()
265            .to_pem()
266            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit public to_pem: {e}")))?;
267        let now = now_secs();
268        sqlx::query(
269            "INSERT INTO auth.biscuit_root_keys
270                 (kid, private_pem, public_pem, created_at, rotated_at)
271             VALUES ($1, $2, $3, $4, NULL)",
272        )
273        .bind(&kid)
274        .bind(private_pem.as_bytes())
275        .bind(&public_pem)
276        .bind(now)
277        .execute(pool)
278        .await
279        .map_err(|e| Error::Backend(anyhow::anyhow!("insert auth.biscuit_root_keys (pg): {e}")))?;
280        Ok(BiscuitConfig::from_active(
281            ActiveRootKey { kid, keypair },
282            Vec::new(),
283        ))
284    }
285}
286
287/// SQLite mirror of [`load_or_init_postgres`].
288#[cfg(feature = "backend-sqlite")]
289pub async fn load_or_init_sqlite(pool: &sqlx::SqlitePool) -> Result<BiscuitConfig> {
290    use sqlx::Row;
291    let row = sqlx::query(
292        "SELECT kid, private_pem
293         FROM auth.biscuit_root_keys
294         WHERE rotated_at IS NULL
295         ORDER BY created_at DESC
296         LIMIT 1",
297    )
298    .fetch_optional(pool)
299    .await
300    .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.biscuit_root_keys (sqlite): {e}")))?;
301
302    if let Some(row) = row {
303        let kid: String = row.get("kid");
304        let pem_bytes: Vec<u8> = row.get("private_pem");
305        let pem = std::str::from_utf8(&pem_bytes)
306            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit private_pem utf8: {e}")))?;
307        let keypair = KeyPair::from_private_key_pem(pem)
308            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit from_private_key_pem: {e}")))?;
309        Ok(BiscuitConfig::from_active(
310            ActiveRootKey { kid, keypair },
311            Vec::new(),
312        ))
313    } else {
314        let keypair = KeyPair::new();
315        let kid = mint_kid(&keypair.public());
316        let private_pem = keypair
317            .to_private_key_pem()
318            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit to_private_key_pem: {e}")))?
319            .to_string();
320        let public_pem = keypair
321            .public()
322            .to_pem()
323            .map_err(|e| Error::Backend(anyhow::anyhow!("biscuit public to_pem: {e}")))?;
324        let now = now_secs();
325        sqlx::query(
326            "INSERT INTO auth.biscuit_root_keys
327                 (kid, private_pem, public_pem, created_at, rotated_at)
328             VALUES (?, ?, ?, ?, NULL)",
329        )
330        .bind(&kid)
331        .bind(private_pem.as_bytes())
332        .bind(&public_pem)
333        .bind(now)
334        .execute(pool)
335        .await
336        .map_err(|e| Error::Backend(anyhow::anyhow!("insert auth.biscuit_root_keys (sqlite): {e}")))?;
337        Ok(BiscuitConfig::from_active(
338            ActiveRootKey { kid, keypair },
339            Vec::new(),
340        ))
341    }
342}
343
344/// Mint a deterministic-ish kid from the public key bytes — short
345/// base64 prefix so two distinct keypairs collide with negligible
346/// probability and operators can eyeball-correlate rows. Stable: same
347/// input bytes yield the same kid.
348fn mint_kid(public_key: &PublicKey) -> String {
349    let bytes = public_key.to_bytes();
350    let prefix = if bytes.len() >= 16 { &bytes[..16] } else { &bytes };
351    format!("kid_{}", data_encoding::BASE64URL_NOPAD.encode(prefix))
352}
353
354fn now_secs() -> f64 {
355    std::time::SystemTime::now()
356        .duration_since(std::time::UNIX_EPOCH)
357        .unwrap_or_default()
358        .as_secs_f64()
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn cfg() -> BiscuitConfig {
366        BiscuitConfig::generate_ephemeral()
367    }
368
369    #[test]
370    fn issue_and_verify_round_trip() {
371        let cfg = cfg();
372        let token = cfg
373            .issue(|b| {
374                b.fact("user(\"alice\")")
375                    .and_then(|b| b.fact("role(\"admin\")"))
376            })
377            .expect("issue");
378        cfg.verify(&token, |a| a.policy("allow if user(\"alice\")"))
379            .expect("verify");
380    }
381
382    #[test]
383    fn verify_rejects_tampered_token() {
384        let cfg = cfg();
385        let token = cfg
386            .issue(|b| b.fact("user(\"alice\")"))
387            .expect("issue");
388        // Flip a single byte mid-token; the signature verify should fail.
389        let mut bytes = token.into_bytes();
390        let half = bytes.len() / 2;
391        bytes[half] ^= 0x01;
392        let tampered = String::from_utf8_lossy(&bytes).to_string();
393        let result = cfg.verify(&tampered, |a| a.policy("allow if user(\"alice\")"));
394        assert!(matches!(result, Err(Error::Backend(_))));
395    }
396
397    #[test]
398    fn verify_with_unknown_root_key_fails() {
399        // Issue with cfg_a, verify with cfg_b — different root keypair.
400        let cfg_a = cfg();
401        let cfg_b = cfg();
402        let token = cfg_a
403            .issue(|b| b.fact("user(\"alice\")"))
404            .expect("issue");
405        let result = cfg_b.verify(&token, |a| a.policy("allow if user(\"alice\")"));
406        assert!(matches!(result, Err(Error::Backend(_))));
407    }
408
409    #[test]
410    fn attenuate_produces_valid_child_token() {
411        let cfg = cfg();
412        let token = cfg
413            .issue(|b| b.fact("user(\"alice\")"))
414            .expect("issue");
415        let pubkey = cfg.active_public_key();
416        // Attenuate with an extra fact + a check: reading must be
417        // explicitly allowed.
418        let attenuated = attenuate(&token, pubkey, |b| {
419            b.check("check if operation(\"read\")")
420        })
421        .expect("attenuate");
422        // With operation("read") → allowed.
423        cfg.verify(&attenuated, |a| {
424            a.fact("operation(\"read\")")
425                .and_then(|a| a.policy("allow if user(\"alice\")"))
426        })
427        .expect("read should pass");
428        // Without operation("read") → check fails (no fact => check
429        // unsatisfied), authorize errors.
430        let result = cfg.verify(&attenuated, |a| a.policy("allow if user(\"alice\")"));
431        assert!(matches!(result, Err(Error::Backend(_))));
432    }
433
434    #[test]
435    fn time_based_check_rejects_after_expiry() {
436        let cfg = cfg();
437        // Issue an unrestricted token; the attenuation pins a time check.
438        let token = cfg
439            .issue(|b| b.fact("user(\"alice\")"))
440            .expect("issue");
441        let pubkey = cfg.active_public_key();
442        let attenuated = attenuate(&token, pubkey, |b| {
443            // Note: time/2026 in past is rejected; pin to year 2000 so
444            // any "now" the test runs in is past expiry.
445            b.check("check if time($now), $now < 2000-01-01T00:00:00Z")
446        })
447        .expect("attenuate");
448        let result = cfg.verify(&attenuated, |a| {
449            a.time().policy("allow if user(\"alice\")")
450        });
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}