vauban-claim 0.1.0

Vauban Claim Algebra โ€” reference implementation of draft-vauban-claim-algebra-00 (post-quantum claim sextuplet + 5 composition operators, canonical CBOR/JSON codec).
Documentation
//! Subject primitive (CDDL ยง5.1).

use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};

use crate::error::CompositionError;

/// Subject discriminator.
///
/// CDDL: `subject-type = "wallet" / "did" / "enclave-measurement" /
/// "artefact-digest" / "x509-dn"`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SubjectType {
    /// Wallet address (Starknet felt252 32B or Ethereum 20B).
    Wallet,
    /// W3C DID URI.
    Did,
    /// TEE enclave measurement (TDX MRTD or SEV-SNP).
    EnclaveMeasurement,
    /// Content-addressed artefact digest.
    ArtefactDigest,
    /// X.509 Subject DN (RFC 5280).
    X509Dn,
}

/// Subject identifier โ€” bytes (e.g. wallet, digest) or text (e.g. DID URI).
///
/// CDDL: `subject-id = bstr / tstr`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SubjectId {
    /// Text-encoded identifier (CBOR tstr / JSON string).
    Text(String),
    /// Byte-encoded identifier (CBOR bstr / JSON number array).
    Bytes(Vec<u8>),
}

impl Serialize for SubjectId {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        match self {
            Self::Text(t) => s.serialize_str(t),
            Self::Bytes(b) => s.serialize_bytes(b),
        }
    }
}

impl<'de> Deserialize<'de> for SubjectId {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        struct V;
        impl<'de> serde::de::Visitor<'de> for V {
            type Value = SubjectId;
            fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
                f.write_str("a Subject id (CBOR tstr/bstr or JSON string / number array)")
            }
            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
                Ok(SubjectId::Text(v.into()))
            }
            fn visit_string<E: serde::de::Error>(self, v: alloc::string::String) -> Result<Self::Value, E> {
                Ok(SubjectId::Text(v))
            }
            fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
                Ok(SubjectId::Bytes(v.to_vec()))
            }
            fn visit_byte_buf<E: serde::de::Error>(self, v: alloc::vec::Vec<u8>) -> Result<Self::Value, E> {
                Ok(SubjectId::Bytes(v))
            }
            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
            where
                A: serde::de::SeqAccess<'de>,
            {
                let mut buf: alloc::vec::Vec<u8> = match seq.size_hint() {
                    Some(n) => alloc::vec::Vec::with_capacity(n),
                    None => alloc::vec::Vec::new(),
                };
                while let Some(b) = seq.next_element::<u8>()? {
                    buf.push(b);
                }
                Ok(SubjectId::Bytes(buf))
            }
        }
        d.deserialize_any(V)
    }
}

/// Subject (CDDL ยง5.1).
///
/// Identifies the entity to which the Claim pertains. Conformance
/// invariant I-1: `type` and `id` syntactic shape MUST agree.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Subject {
    /// Discriminator selecting the variant of `id`.
    #[serde(rename = "type")]
    pub subject_type: SubjectType,
    /// Identifier payload.
    pub id: SubjectId,
    /// Optional verifier-key binding (JWK thumbprint, Starknet pubkey, โ€ฆ).
    #[serde(skip_serializing_if = "Option::is_none", default, with = "opt_bytes")]
    pub binding: Option<Vec<u8>>,
}

/// Helper to encode `Option<Vec<u8>>` as CBOR bstr (or omitted) instead of array of u8.
mod opt_bytes {
    use alloc::vec::Vec;
    use serde::{Deserialize, Deserializer, Serialize, Serializer};
    pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
        match v {
            Some(b) => serde_bytes::Bytes::new(b).serialize(s),
            None => s.serialize_none(),
        }
    }
    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
        let opt: Option<serde_bytes::ByteBuf> = Option::deserialize(d)?;
        Ok(opt.map(serde_bytes::ByteBuf::into_vec))
    }
}

impl Subject {
    /// Construct a Subject and validate the `type`/`id` shape (I-1).
    pub fn new(
        subject_type: SubjectType,
        id: SubjectId,
        binding: Option<Vec<u8>>,
    ) -> Result<Self, CompositionError> {
        let s = Self {
            subject_type,
            id,
            binding,
        };
        s.validate_shape()?;
        Ok(s)
    }

    /// Convenience: wallet subject from hex key bytes.
    pub fn wallet(key: Vec<u8>) -> Self {
        Self { subject_type: SubjectType::Wallet, id: SubjectId::Bytes(key), binding: None }
    }

    /// Subject type discriminant.
    pub fn subject_type(&self) -> &SubjectType { &self.subject_type }
    /// Subject type as string (for transcript absorption).
    pub fn subject_type_str(&self) -> &str {
        match self.subject_type {
            SubjectType::Wallet => "wallet",
            SubjectType::Did => "did",
            SubjectType::EnclaveMeasurement => "enclave-measurement",
            SubjectType::ArtefactDigest => "artefact-digest",
            SubjectType::X509Dn => "x509-dn",
        }
    }

    /// Raw bytes identifier, if byte-encoded.
    pub fn id_bytes(&self) -> Option<&[u8]> {
        if let SubjectId::Bytes(b) = &self.id { Some(b) } else { None }
    }

    /// UTF-8 identifier, if text-encoded.
    pub fn id_utf8(&self) -> Option<&str> {
        if let SubjectId::Text(s) = &self.id { Some(s) } else { None }
    }

    /// Optional verifier-key binding bytes.
    pub fn binding_bytes(&self) -> Option<&[u8]> { self.binding.as_deref() }

    /// Enforce I-1: discriminator must match the `id` shape.
    pub fn validate_shape(&self) -> Result<(), CompositionError> {
        match (&self.subject_type, &self.id) {
            (SubjectType::Did, SubjectId::Text(s)) if s.starts_with("did:") => Ok(()),
            (SubjectType::Did, _) => Err(CompositionError::Invariant(
                "I-1: did subject requires a textual id starting with \"did:\"",
            )),
            (SubjectType::Wallet, SubjectId::Bytes(b)) if b.len() == 32 || b.len() == 20 => {
                Ok(())
            }
            (SubjectType::Wallet, _) => Err(CompositionError::Invariant(
                "I-1: wallet subject requires bytes(20) or bytes(32)",
            )),
            (SubjectType::EnclaveMeasurement, SubjectId::Bytes(b)) if b.len() == 48 => Ok(()),
            (SubjectType::EnclaveMeasurement, _) => Err(CompositionError::Invariant(
                "I-1: enclave-measurement subject requires bytes(48)",
            )),
            (SubjectType::ArtefactDigest, SubjectId::Bytes(b)) if !b.is_empty() => Ok(()),
            (SubjectType::ArtefactDigest, _) => Err(CompositionError::Invariant(
                "I-1: artefact-digest subject requires non-empty bytes",
            )),
            (SubjectType::X509Dn, SubjectId::Bytes(b)) if !b.is_empty() => Ok(()),
            (SubjectType::X509Dn, _) => Err(CompositionError::Invariant(
                "I-1: x509-dn subject requires non-empty DER bytes",
            )),
        }
    }
}