obsigil 0.1.0

Mandate-token format: a JWT-like token split into a public manifest and an encrypted mandate (AES-SIV / AES-GCM-SIV), on RustCrypto
Documentation
//! Format-level enumerations and constants (spec §3, §4.2, §5, §7).

use uuid::Uuid;

/// Seconds since the Unix epoch (JWT NumericDate); the type of `exp`.
pub type NumericDate = i64;

/// The AEAD that seals a half, named by its single-character algorithm
/// code in the clear next to the separator (spec §5).
///
/// ```rust
/// use obsigil::Alg;
/// assert_eq!(Alg::Siv.code(), '0');
/// assert_eq!(Alg::from_code('0'), Some(Alg::Siv));
/// assert_eq!(Alg::from_code('z'), None); // a code this build does not implement
/// ```
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub enum Alg {
    /// Code `0` — AES-SIV (RFC 5297). Mandatory to implement.
    Siv,
    /// Code `1` — AES-GCM-SIV (RFC 8452). Optional (`gcm-siv` feature).
    GcmSiv,
}

impl Alg {
    /// The clear-text code character for this algorithm.
    pub fn code(self) -> char {
        match self {
            Alg::Siv => '0',
            Alg::GcmSiv => '1',
        }
    }

    /// Parse an algorithm code character. Returns `None` for any code this
    /// build does not implement (spec §5).
    pub fn from_code(c: char) -> Option<Alg> {
        match c {
            '0' => Some(Alg::Siv),
            '1' => Some(Alg::GcmSiv),
            _ => None,
        }
    }
}

/// A token's text encoding, selected for the whole token by the separator
/// (spec §3): `.` => b64, `~` => hex.
///
/// ```rust
/// use obsigil::Encoding;
/// assert_eq!(Encoding::B64.separator(), '.');
/// assert_eq!(Encoding::from_separator('~'), Some(Encoding::Hex));
/// assert_eq!(Encoding::from_separator('!'), None);
/// ```
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Encoding {
    /// URL-safe base64, no padding. Separator `.`.
    B64,
    /// Lowercase hex. Separator `~`.
    Hex,
}

impl Encoding {
    /// The separator character that names this encoding.
    pub fn separator(self) -> char {
        match self {
            Encoding::B64 => '.',
            Encoding::Hex => '~',
        }
    }

    /// Map a separator character to its encoding.
    pub fn from_separator(c: char) -> Option<Encoding> {
        match c {
            '.' => Some(Encoding::B64),
            '~' => Some(Encoding::Hex),
            _ => None,
        }
    }
}

/// A half's serialization, named by a one-byte tag sealed inside it
/// (spec §7). Only pure data formats are registered.
///
/// ```rust
/// use obsigil::Format;
/// assert_eq!(Format::Json.tag(), b'j');
/// assert_eq!(Format::from_tag(b'j'), Some(Format::Json));
/// assert_eq!(Format::from_tag(b'x'), None);
/// ```
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub enum Format {
    /// Tag `j` — JSON.
    Json,
    /// Tag `t` — TOML (`toml` feature).
    Toml,
    /// Tag `c` — CBOR (`cbor` feature).
    Cbor,
}

impl Format {
    /// The one-byte serialization tag.
    pub fn tag(self) -> u8 {
        match self {
            Format::Json => b'j',
            Format::Toml => b't',
            Format::Cbor => b'c',
        }
    }

    /// Map a serialization tag byte to its format.
    pub fn from_tag(tag: u8) -> Option<Format> {
        match tag {
            b'j' => Some(Format::Json),
            b't' => Some(Format::Toml),
            b'c' => Some(Format::Cbor),
            _ => None,
        }
    }
}

/// The public 64-byte manifest key pinned by the spec (§4.2). Every
/// conformant implementation MUST use this exact value. It is public: it
/// opens *and* forges manifests, which is the point — the manifest is an
/// encoding wrapper, not a security layer.
pub const MANIFEST_KEY: [u8; 64] = [
    0x38, 0x12, 0x84, 0x63, 0x3d, 0x02, 0xea, 0x5f, //
    0x35, 0xdf, 0x85, 0x96, 0xb5, 0xcc, 0x42, 0x18, //
    0x31, 0x00, 0x60, 0x46, 0x8e, 0x8b, 0x46, 0x54, //
    0x55, 0xa4, 0x15, 0x17, 0x4e, 0xa6, 0xe9, 0x66, //
    0xa9, 0xf4, 0x8e, 0xec, 0x4b, 0xa4, 0x46, 0xdd, //
    0xfc, 0x8b, 0x78, 0x58, 0x78, 0x95, 0x35, 0x6f, //
    0x45, 0xa7, 0x5a, 0x1a, 0xb7, 0x41, 0x94, 0x54, //
    0xdd, 0x9f, 0x7a, 0xa8, 0xa9, 0x5d, 0xbd, 0xd5, //
];

/// The mandate's issue time, derived from a UUIDv7 `tid` (spec §11.3): the
/// 48-bit big-endian Unix-millisecond field, floored to whole seconds for
/// NumericDate semantics. obsigil defines no separate `iat`.
///
/// ```rust
/// use obsigil::{tid_issued_at, Uuid};
/// // A UUIDv7 whose 48-bit timestamp field is 1000 ms.
/// let tid = Uuid::from_bytes([
///     0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x70, 0x00,
///     0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
/// ]);
/// assert_eq!(tid_issued_at(tid), 1); // 1000 ms -> 1 second
/// ```
pub fn tid_issued_at(tid: Uuid) -> NumericDate {
    let b = tid.as_bytes();
    let ms = (u64::from(b[0]) << 40)
        | (u64::from(b[1]) << 32)
        | (u64::from(b[2]) << 24)
        | (u64::from(b[3]) << 16)
        | (u64::from(b[4]) << 8)
        | u64::from(b[5]);
    (ms / 1000) as NumericDate
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn manifest_key_matches_spec_hex() {
        let mut hex = String::new();
        crate::encoding::encode_into(&MANIFEST_KEY, Encoding::Hex, &mut hex);
        assert_eq!(
            hex,
            "381284633d02ea5f35df8596b5cc4218310060468e8b465455a415174ea6e966\
             a9f48eec4ba446ddfc8b78587895356f45a75a1ab7419454dd9f7aa8a95dbdd5"
        );
    }

    #[test]
    fn tid_issued_at_reads_the_48_bit_ms_field() {
        // First 6 bytes = 0x0000_0001_86A0 = 100_000 ms -> 100 s.
        let tid = Uuid::from_bytes([
            0x00, 0x00, 0x00, 0x01, 0x86, 0xa0, 0x70, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00,
        ]);
        assert_eq!(tid_issued_at(tid), 100);
    }
}