liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! Bind the existing credential bytes through Lys's published signed-binding route.

use super::{GrantStreamConfig, StreamError, StreamState, http::Http};
use crate::auth_revocation::{
    AuthenticatedBinding, BoundRightClaim, Digest32, GrantBindingSource, GrantId, GrantLogId,
    Revision, RevocationRefusal, RightAction,
};
use lys_pass::binding::VerifiedBinding;
use lys_pass::{KeySet, VerifiedPass};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::sync::Arc;

/// The key cache and its identity remain the same through the admission fence.
pub(super) struct BindingSource {
    keys: KeySet,
    issuer: String,
    identity: Digest32,
    state: Arc<StreamState>,
    config: GrantStreamConfig,
    http: Http,
    handle: tokio::runtime::Handle,
}

impl std::fmt::Debug for BindingSource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("BindingSource")
            .field("identity", &self.identity)
            .finish_non_exhaustive()
    }
}

#[derive(Serialize)]
struct Question<'a> {
    binding: u32,
    pass: String,
    iss: &'a str,
    sub: &'a str,
    aud: &'a str,
    iat: u64,
    exp: u64,
    grants: BTreeSet<&'a str>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Answer {
    grant_binding: String,
    revision: u64,
}

impl Drop for Answer {
    fn drop(&mut self) {
        zeroize::Zeroize::zeroize(&mut self.grant_binding);
    }
}

impl BindingSource {
    pub fn new(
        lys: &crate::config::types::LysConfig,
        config: GrantStreamConfig,
        state: Arc<StreamState>,
        credential: zeroize::Zeroizing<String>,
        handle: tokio::runtime::Handle,
    ) -> Result<Self, StreamError> {
        let key_json = super::http::read_file(&lys.keys_path, config.max_keyset_bytes)?;
        let keys = KeySet::from_json(&key_json)
            .map_err(|_| StreamError::named("grant_stream_keys_invalid"))?;
        // The policy pins the whole locally validated signing-key cache. A key
        // rotation changes that trust anchor only on an explicit server start.
        let identity = Digest32(Sha256::digest(key_json.as_bytes()).into());
        let http = Http::new(
            lys.api_url.as_deref().unwrap_or(&lys.issuer),
            credential,
            config.max_response_bytes,
            state.cancellation(),
        )?;
        Ok(Self {
            keys,
            issuer: lys.issuer.clone(),
            identity,
            state,
            config,
            http,
            handle,
        })
    }

    pub const fn identity(&self) -> Digest32 {
        self.identity
    }

    fn token<'a>(&self, credential: &'a [u8]) -> Result<&'a str, RevocationRefusal> {
        if credential.len() > self.config.max_credential_bytes {
            return Err(RevocationRefusal::BindingMissing);
        }
        let liminal_protocol::credential::WireCredential::LysPass(bytes) =
            liminal_protocol::credential::WireCredential::classify(credential)
                .map_err(|_| RevocationRefusal::BindingMissing)?
        else {
            return Err(RevocationRefusal::BindingMissing);
        };
        std::str::from_utf8(bytes).map_err(|_| RevocationRefusal::BindingMissing)
    }

    fn acquire(
        &self,
        token: &str,
        pass: &VerifiedPass,
    ) -> Result<VerifiedBinding, RevocationRefusal> {
        let claims = pass.claims();
        if claims.rights.len() > self.config.max_rights || claims.rights_truncated.is_some() {
            return Err(RevocationRefusal::BindingMissing);
        }
        let question = Question {
            binding: lys_pass::binding::BINDING_VERSION,
            pass: lys_pass::binding::pass_digest(token),
            iss: &claims.iss,
            sub: &claims.sub,
            aud: &claims.aud,
            iat: claims.iat,
            exp: claims.exp,
            grants: claims
                .rights
                .iter()
                .map(|right| right.grant.as_str())
                .collect(),
        };
        let answer = std::thread::scope(|scope| {
            scope
                .spawn(|| {
                    self.handle
                        .block_on(self.http.post::<_, Answer>("grants/bindings", &question))
                })
                .join()
        })
        .map_err(|_| RevocationRefusal::BindingMissing)?
        .map_err(|_| RevocationRefusal::BindingMissing)?;
        if answer.grant_binding.len() > self.config.max_credential_bytes {
            return Err(RevocationRefusal::BindingMissing);
        }
        let binding = VerifiedBinding::verify(
            &answer.grant_binding,
            token,
            pass,
            &self.keys,
            &self.state.log,
        )
        .map_err(|_| RevocationRefusal::BindingMissing)?;
        if answer.revision != binding.revision()
            || binding
                .claims()
                .dependencies
                .iter()
                .any(|dependency| dependency.path.len() > self.config.max_chain_grants)
        {
            return Err(RevocationRefusal::BindingMissing);
        }
        Ok(binding)
    }

    fn rights(
        &self,
        pass: &VerifiedPass,
        binding: &VerifiedBinding,
    ) -> Result<Vec<BoundRightClaim>, RevocationRefusal> {
        let mut rights = Vec::new();
        let mut admission = Vec::new();
        for right in &pass.claims().rights {
            if right.mode != lys_pass::rights::Mode::Outright {
                continue;
            }
            let dependency = binding
                .dependency(&right.grant)
                .ok_or(RevocationRefusal::BindingMissing)?;
            let chain = dependency
                .path
                .iter()
                .map(|grant| GrantId::new(grant).map_err(|_| RevocationRefusal::BindingMissing))
                .collect::<Result<Vec<_>, _>>()?;
            let target = &self.config.admission;
            if right.resource.kind == target.kind
                && right.resource.id == target.id
                && right.actions.contains(&target.action)
                && !admission.contains(&chain)
            {
                if admission.len() >= self.config.max_chains {
                    return Err(RevocationRefusal::BindingMissing);
                }
                admission.push(chain.clone());
            }
            for mapping in &self.config.subjects {
                if mapping.kind != right.resource.kind || mapping.resource != right.resource.id {
                    continue;
                }
                for (word, action) in [
                    ("publish", crate::auth_lys::HotAction::Publish),
                    ("subscribe", crate::auth_lys::HotAction::Subscribe),
                    ("call", crate::auth_lys::HotAction::Call),
                    ("serve", crate::auth_lys::HotAction::Serve),
                ] {
                    if right.actions.iter().any(|granted| granted == word) {
                        self.merge(
                            &mut rights,
                            RightAction::Hot(action),
                            &mapping.prefix,
                            &chain,
                        )?;
                    }
                }
            }
        }
        if admission.is_empty() || rights.len() >= self.config.max_rights {
            return Err(RevocationRefusal::BindingMissing);
        }
        rights.push(BoundRightClaim {
            action: RightAction::Connect,
            prefix: String::new(),
            chains: admission,
        });
        Ok(rights)
    }

    fn merge(
        &self,
        rights: &mut Vec<BoundRightClaim>,
        action: RightAction,
        prefix: &str,
        chain: &[GrantId],
    ) -> Result<(), RevocationRefusal> {
        if let Some(known) = rights
            .iter_mut()
            .find(|known| known.action == action && known.prefix == prefix)
        {
            if !known.chains.iter().any(|known| known == chain) {
                if known.chains.len() >= self.config.max_chains {
                    return Err(RevocationRefusal::BindingMissing);
                }
                known.chains.push(chain.to_vec());
            }
        } else {
            if rights.len() >= self.config.max_rights {
                return Err(RevocationRefusal::BindingMissing);
            }
            rights.push(BoundRightClaim {
                action,
                prefix: prefix.into(),
                chains: vec![chain.to_vec()],
            });
        }
        Ok(())
    }
}

impl GrantBindingSource for BindingSource {
    fn pass_digest(&self, credential: &[u8]) -> Result<Digest32, RevocationRefusal> {
        Ok(Digest32(
            Sha256::digest(self.token(credential)?.as_bytes()).into(),
        ))
    }

    fn binding_for(&self, credential: &[u8]) -> Result<AuthenticatedBinding, RevocationRefusal> {
        let token = self.token(credential)?;
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_err(|_| RevocationRefusal::BindingMissing)?
            .as_secs();
        let pass = VerifiedPass::verify(token, &self.keys, &self.issuer, "liminal", now)
            .map_err(|_| RevocationRefusal::BindingMissing)?;
        let binding = self.acquire(token, &pass)?;
        Ok(AuthenticatedBinding {
            issuer: self.identity,
            audience: "liminal".into(),
            holder: pass.claims().holder.id.as_bytes().to_vec(),
            pass_digest: self.pass_digest(credential)?,
            log: GrantLogId::new(&binding.claims().log.identity, binding.claims().log.epoch)
                .map_err(|_| RevocationRefusal::LogReset)?,
            required: Revision::new(binding.revision()),
            rights: self.rights(&pass, &binding)?,
        })
    }
}