obsigil 0.4.0

A shared-secret JWT alternative: a mandate-token format splitting a public, advisory manifest from a secret-sealed, authenticated mandate (AES-SIV / AES-GCM-SIV), with fields in canonical CBOR
Documentation
//! Minting — the trusted issuer side (the Construction section, §5). Configure an [`Issuer`]
//! once with the secret key and defaults, mint many tokens. Errors are
//! descriptive: minting is not bearer-facing, so detail is not an oracle.

use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::Serialize;
use uuid::Uuid;

use crate::aead::seal;
use crate::encoding::{encode_into, encoded_len};
use crate::error::MintError;
use crate::key::MandateKey;
use crate::serial;
use crate::types::{Alg, Encoding, NumericDate, MANIFEST_KEY};

/// A configured token issuer. Holds the secret mandate key and the default
/// algorithm/serialization/encoding for the tokens it mints. Mint under one
/// key (create more issuers to mint under others).
pub struct Issuer {
    key: MandateKey,
    mandate_alg: Alg,
    manifest_alg: Alg,
    encoding: Encoding,
}

impl Issuer {
    /// A new issuer with spec defaults: AES-SIV (code 0), canonical CBOR
    /// fields, and the `.`/b64 encoding for the whole token.
    ///
    /// ```rust
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// use obsigil::{Issuer, MandateKey, NoApp};
    /// let key = MandateKey::from_bytes([42u8; 64])?;
    /// let token = Issuer::new(key)
    ///     .clauses(&NoApp::default())
    ///     .exp(4_000_000_000)
    ///     .mint()?;
    /// assert!(token.starts_with('.')); // mandate-only: no manifest half
    /// # Ok(()) }
    /// ```
    pub fn new(key: MandateKey) -> Self {
        Issuer {
            key,
            mandate_alg: Alg::Siv,
            manifest_alg: Alg::Siv,
            encoding: Encoding::B64,
        }
    }

    /// Set the mandate's algorithm code (default [`Alg::Siv`]).
    pub fn alg(mut self, alg: Alg) -> Self {
        self.mandate_alg = alg;
        self
    }

    /// Set the manifest's algorithm code (default [`Alg::Siv`]).
    pub fn manifest_alg(mut self, alg: Alg) -> Self {
        self.manifest_alg = alg;
        self
    }

    /// Set the token-wide text encoding (default [`Encoding::B64`]).
    pub fn encoding(mut self, encoding: Encoding) -> Self {
        self.encoding = encoding;
        self
    }

    /// Begin minting a mandate carrying the application clauses `app`. Use
    /// [`crate::NoApp`] for a mandate with only reserved clauses.
    pub fn clauses<'a, T: Serialize>(&'a self, app: &'a T) -> MintBuilder<'a, T> {
        MintBuilder {
            issuer: self,
            app,
            exp: None,
            tid: None,
            iss: None,
            aud: None,
            sub: None,
            manifest_plain: None,
        }
    }
}

/// Builder for a single token. `exp` is required; `tid` defaults to a fresh
/// UUIDv7 (the `tid` field, §8.2).
pub struct MintBuilder<'a, T> {
    issuer: &'a Issuer,
    app: &'a T,
    exp: Option<NumericDate>,
    tid: Option<Uuid>,
    iss: Option<String>,
    aud: Option<Vec<String>>,
    sub: Option<String>,
    manifest_plain: Option<Result<Vec<u8>, MintError>>,
}

impl<'a, T: Serialize> MintBuilder<'a, T> {
    /// Set the authoritative expiry as an absolute NumericDate (the `exp` field, §8.3).
    pub fn exp(mut self, exp: NumericDate) -> Self {
        self.exp = Some(exp);
        self
    }

    /// Set the expiry as a duration from now (the `exp` field, §8.3).
    ///
    /// ```rust
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// use std::time::Duration;
    /// use obsigil::{Issuer, MandateKey, NoApp, Verifier};
    /// let token = Issuer::new(MandateKey::from_bytes([42u8; 64])?)
    ///     .clauses(&NoApp::default())
    ///     .expires_in(Duration::from_secs(3600)) // valid for one hour
    ///     .mint()?;
    /// let key = MandateKey::from_bytes([42u8; 64])?;
    /// assert!(Verifier::new().key(&key).clauses::<NoApp>(&token).is_ok());
    /// # Ok(()) }
    /// ```
    pub fn expires_in(mut self, ttl: Duration) -> Self {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs() as NumericDate)
            .unwrap_or(0);
        let ttl = NumericDate::try_from(ttl.as_secs()).unwrap_or(NumericDate::MAX);
        self.exp = Some(now.saturating_add(ttl));
        self
    }

    /// Override the auto-generated UUIDv7 `tid` (the `tid` field, §8.2).
    pub fn tid(mut self, tid: Uuid) -> Self {
        self.tid = Some(tid);
        self
    }

    /// Set the mandate's `iss` clause, for audit (the `iss` field, §8.6).
    pub fn issuer(mut self, iss: impl Into<String>) -> Self {
        self.iss = Some(iss.into());
        self
    }

    /// Set the `aud` clause — the intended verifiers (the `aud` field, §8.4). Must be
    /// non-empty.
    ///
    /// ```rust
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// use obsigil::{Issuer, MandateKey, NoApp, Verifier};
    /// let token = Issuer::new(MandateKey::from_bytes([42u8; 64])?)
    ///     .clauses(&NoApp::default())
    ///     .exp(4_000_000_000)
    ///     .audience(["api", "admin-api"])
    ///     .mint()?;
    /// let key = MandateKey::from_bytes([42u8; 64])?;
    /// // A verifier in the audience succeeds; one outside fails.
    /// assert!(Verifier::new().key(&key).audience("api").now(1_000_000_000)
    ///     .clauses::<NoApp>(&token).is_ok());
    /// assert!(Verifier::new().key(&key).audience("other").now(1_000_000_000)
    ///     .clauses::<NoApp>(&token).is_err());
    /// # Ok(()) }
    /// ```
    pub fn audience<I, S>(mut self, audiences: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.aud = Some(audiences.into_iter().map(Into::into).collect());
        self
    }

    /// Set the `sub` clause — the subject authorized (the `sub` field, §8.5).
    pub fn subject(mut self, sub: impl Into<String>) -> Self {
        self.sub = Some(sub.into());
        self
    }

    /// Attach a public manifest half with the required `iss` claim and the
    /// application claims `claims` (the manifest construction, §5.2; the `iss` field, §8.6). Sealed keyless under
    /// the public manifest key.
    ///
    /// ```rust
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// use obsigil::{claims, Claims, Issuer, MandateKey, NoApp};
    /// use serde::{Deserialize, Serialize};
    ///
    /// #[derive(Serialize, Deserialize)]
    /// struct ClaimData { theme: String }
    ///
    /// let token = Issuer::new(MandateKey::from_bytes([42u8; 64])?)
    ///     .clauses(&NoApp::default())
    ///     .exp(4_000_000_000)
    ///     .manifest("auth.example", &ClaimData { theme: "dark".into() })
    ///     .mint()?;
    ///
    /// // Reads with no secret — advisory only (the non-authoritative-manifest rule of the Security Considerations, §16.7).
    /// let advisory: Claims<ClaimData> = claims(&token).expect("present");
    /// assert_eq!(advisory.issuer(), "auth.example");
    /// assert_eq!(advisory.app().theme, "dark");
    /// # Ok(()) }
    /// ```
    pub fn manifest<M: Serialize>(mut self, iss: impl Into<String>, claims: &M) -> Self {
        self.manifest_plain = Some(serial::to_manifest_plaintext(&iss.into(), claims));
        self
    }

    /// Mint the token (the Token structure section, §4; the Construction section, §5). Errors if `exp` is unset, `aud` is
    /// empty, or a chosen algorithm/serialization is not built in.
    pub fn mint(self) -> Result<String, MintError> {
        let exp = self.exp.ok_or(MintError::Missing("exp"))?;
        if let Some(aud) = &self.aud {
            if aud.is_empty() {
                return Err(MintError::EmptyAudience);
            }
        }
        let tid = self.tid.unwrap_or_else(Uuid::now_v7);
        // A provided `tid` must be a well-formed UUIDv7 — version 7 and the RFC
        // 4122 variant (the `tid` field, §8.2); the auto-generated one always is. Catching
        // it here stops any front-end from minting a token the verifier would
        // only reject later.
        if tid.get_version_num() != 7 || tid.get_variant() != uuid::Variant::RFC4122 {
            return Err(MintError::BadTid);
        }

        // Seal both halves first (the manifest is optional). The mandate
        // plaintext carries the secret clauses, so it is wiped on drop; the
        // keyless manifest plaintext is public and needs no wipe.
        let mandate_plain = zeroize::Zeroizing::new(serial::to_mandate_plaintext(
            exp,
            tid,
            self.iss.as_deref(),
            self.aud.as_deref(),
            self.sub.as_deref(),
            self.app,
        )?);
        let mandate_sealed = seal(
            &mandate_plain,
            self.issuer.key.bytes(),
            self.issuer.mandate_alg,
        )?;
        let manifest_sealed = match self.manifest_plain {
            Some(result) => Some(seal(&result?, &MANIFEST_KEY, self.issuer.manifest_alg)?),
            None => None,
        };

        // Assemble the token in a single allocation (the Token structure section, §4):
        //   [ manifest_text manifest_code ] SEP mandate_code mandate_text
        let enc = self.issuer.encoding;
        let cap = manifest_sealed
            .as_ref()
            .map_or(0, |s| encoded_len(s.len(), enc) + 1) // text + code
            + 1 // separator
            + 1 // mandate algorithm code
            + encoded_len(mandate_sealed.len(), enc);
        let mut token = String::with_capacity(cap);
        if let Some(sealed) = &manifest_sealed {
            encode_into(sealed, enc, &mut token);
            token.push(self.issuer.manifest_alg.code());
        }
        token.push(enc.separator());
        token.push(self.issuer.mandate_alg.code());
        encode_into(&mandate_sealed, enc, &mut token);
        Ok(token)
    }
}