blindplane-access 0.1.0

Signed enterprise access grants, capability policies, revocation and encrypted audit events for Blindplane
Documentation
//! Signed per-subject and scope revocation state.

use crate::AccessError;
use crate::codec::{AccessValidationPolicy, Cursor, push_header, push_string, validate_identifier};
#[cfg(feature = "client")]
use crate::principal::AccessIssuer;
use crate::principal::{TrustedIssuer, issuer_key_id};
#[cfg(feature = "client")]
use crate::signed::sign;
use crate::signed::verify;

const REVOCATION_TAG: u8 = 3;
const REVOCATION_SIGNATURE_DOMAIN: &[u8] = b"blindplane/access/revocation/v1";

/// Monotonic floors signed into a revocation state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RevocationSpec {
    /// Monotonic state revision.
    pub revision: u64,
    /// Grants below this authorization epoch are invalid.
    pub minimum_authorization_epoch: u64,
    /// Grants below this role-key epoch are invalid.
    pub minimum_role_key_epoch: u64,
    /// Issuance time in Unix seconds.
    pub issued_at: u64,
}

/// Signed epoch floors for one `(tenant, subject, scope)` tuple.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RevocationState {
    tenant_id: String,
    subject_id: String,
    scope: String,
    revision: u64,
    minimum_authorization_epoch: u64,
    minimum_role_key_epoch: u64,
    issued_at: u64,
    issuer_id: String,
    issuer_key_id: [u8; 32],
    issuer_public_key: [u8; 32],
    signature: [u8; 64],
}

impl RevocationState {
    /// Issue signed revocation floors for one subject and scope.
    #[cfg(feature = "client")]
    pub fn issue(
        issuer: &AccessIssuer,
        tenant_id: impl Into<String>,
        subject_id: impl Into<String>,
        scope: impl Into<String>,
        spec: RevocationSpec,
    ) -> Result<Self, AccessError> {
        let mut state = Self {
            tenant_id: tenant_id.into(),
            subject_id: subject_id.into(),
            scope: scope.into(),
            revision: spec.revision,
            minimum_authorization_epoch: spec.minimum_authorization_epoch,
            minimum_role_key_epoch: spec.minimum_role_key_epoch,
            issued_at: spec.issued_at,
            issuer_id: issuer.issuer_id().to_owned(),
            issuer_key_id: issuer.key_id(),
            issuer_public_key: issuer.public_key(),
            signature: [0; 64],
        };
        state.validate_structure(&AccessValidationPolicy::default())?;
        state.signature = sign(
            REVOCATION_SIGNATURE_DOMAIN,
            issuer.signing_key(),
            &state.unsigned_bytes(),
        );
        Ok(state)
    }

    /// Tenant identifier authenticated by the signature.
    pub fn tenant_id(&self) -> &str {
        &self.tenant_id
    }

    /// Subject identifier authenticated by the signature.
    pub fn subject_id(&self) -> &str {
        &self.subject_id
    }

    /// Scope identifier authenticated by the signature.
    pub fn scope(&self) -> &str {
        &self.scope
    }

    /// Monotonic revocation-state revision.
    pub const fn revision(&self) -> u64 {
        self.revision
    }

    /// Canonical encoded and signed state.
    pub fn encode(&self) -> Vec<u8> {
        let mut out = self.unsigned_bytes();
        out.extend_from_slice(&self.signature);
        out
    }

    /// Decode, structurally validate, and verify signature math.
    pub fn decode(bytes: &[u8], limits: &AccessValidationPolicy) -> Result<Self, AccessError> {
        let mut cursor = Cursor::new(bytes);
        cursor.take_header(REVOCATION_TAG)?;
        let state = Self {
            tenant_id: cursor.take_string(limits.max_identifier_bytes)?,
            subject_id: cursor.take_string(limits.max_identifier_bytes)?,
            scope: cursor.take_string(limits.max_identifier_bytes)?,
            revision: cursor.take_u64()?,
            minimum_authorization_epoch: cursor.take_u64()?,
            minimum_role_key_epoch: cursor.take_u64()?,
            issued_at: cursor.take_u64()?,
            issuer_id: cursor.take_string(limits.max_identifier_bytes)?,
            issuer_key_id: cursor.take_array32()?,
            issuer_public_key: cursor.take_array32()?,
            signature: cursor.take_array64()?,
        };
        if !cursor.is_empty() {
            return Err(AccessError::TrailingBytes);
        }
        state.validate_structure(limits)?;
        state.verify_signature()?;
        if state.encode() != bytes {
            return Err(AccessError::NonCanonicalEncoding);
        }
        Ok(state)
    }

    /// Verify trust and the exact tuple before exposing epoch floors.
    pub fn verify<'a>(
        &'a self,
        trusted: &TrustedIssuer,
        expected_tenant: &str,
        expected_subject: &str,
        expected_scope: &str,
    ) -> Result<VerifiedRevocation<'a>, AccessError> {
        self.verify_signature()?;
        if !self.issuer_matches(trusted) {
            return Err(AccessError::UntrustedIssuer);
        }
        if self.tenant_id != expected_tenant
            || self.subject_id != expected_subject
            || self.scope != expected_scope
        {
            return Err(AccessError::SubjectMismatch);
        }
        Ok(VerifiedRevocation { state: self })
    }

    fn issuer_matches(&self, trusted: &TrustedIssuer) -> bool {
        self.issuer_id == trusted.issuer_id()
            && self.issuer_key_id == trusted.key_id()
            && self.issuer_public_key == trusted.public_key()
    }

    fn validate_structure(&self, limits: &AccessValidationPolicy) -> Result<(), AccessError> {
        validate_identifier(&self.tenant_id, limits.max_identifier_bytes)?;
        validate_identifier(&self.subject_id, limits.max_identifier_bytes)?;
        validate_identifier(&self.scope, limits.max_identifier_bytes)?;
        validate_identifier(&self.issuer_id, limits.max_identifier_bytes)?;
        if self.revision == 0
            || self.minimum_authorization_epoch == 0
            || self.minimum_role_key_epoch == 0
        {
            return Err(AccessError::InvalidEpoch);
        }
        if self.issuer_key_id != issuer_key_id(&self.issuer_public_key) {
            return Err(AccessError::InvalidKeyIdentity);
        }
        Ok(())
    }

    fn verify_signature(&self) -> Result<(), AccessError> {
        verify(
            REVOCATION_SIGNATURE_DOMAIN,
            &self.issuer_public_key,
            &self.unsigned_bytes(),
            &self.signature,
        )
    }

    fn unsigned_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(256);
        push_header(&mut out, REVOCATION_TAG);
        push_string(&mut out, &self.tenant_id);
        push_string(&mut out, &self.subject_id);
        push_string(&mut out, &self.scope);
        out.extend_from_slice(&self.revision.to_be_bytes());
        out.extend_from_slice(&self.minimum_authorization_epoch.to_be_bytes());
        out.extend_from_slice(&self.minimum_role_key_epoch.to_be_bytes());
        out.extend_from_slice(&self.issued_at.to_be_bytes());
        push_string(&mut out, &self.issuer_id);
        out.extend_from_slice(&self.issuer_key_id);
        out.extend_from_slice(&self.issuer_public_key);
        out
    }
}

/// Revocation state that passed issuer and tuple verification.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VerifiedRevocation<'a> {
    state: &'a RevocationState,
}

impl VerifiedRevocation<'_> {
    #[cfg(feature = "client")]
    pub(crate) fn accepts(
        self,
        trusted: &TrustedIssuer,
        tenant_id: &str,
        subject_id: &str,
        scope: &str,
        authorization_epoch: u64,
        role_key_epoch: u64,
    ) -> Result<(), AccessError> {
        if !self.state.issuer_matches(trusted)
            || self.state.tenant_id != tenant_id
            || self.state.subject_id != subject_id
            || self.state.scope != scope
        {
            return Err(AccessError::SubjectMismatch);
        }
        if authorization_epoch < self.state.minimum_authorization_epoch
            || role_key_epoch < self.state.minimum_role_key_epoch
        {
            return Err(AccessError::Revoked);
        }
        Ok(())
    }
}