use std::time::Duration;
use basil_proto::broker::v1 as pb;
use basil_proto::broker::v1::admin_service_client::AdminServiceClient;
use basil_proto::broker::v1::aead_service_client::AeadServiceClient;
use basil_proto::broker::v1::minting_service_client::MintingServiceClient;
use basil_proto::broker::v1::nats_service_client::NatsServiceClient;
use basil_proto::broker::v1::secret_service_client::SecretServiceClient;
use basil_proto::broker::v1::signing_service_client::SigningServiceClient;
use hyper_util::rt::TokioIo;
use prost::Message;
use prost_types::{Duration as ProtoDuration, Struct, Timestamp, Value as ProtoValue};
use tokio::net::UnixStream;
use tokio::time::timeout;
use tonic::transport::{Channel, Endpoint};
use tonic::{Status, transport::Uri};
use tower::service_fn;
use tracing::{error, trace};
use crate::constants::DEFAULT_CONN_TIMEOUT;
use crate::error::{Error, Result};
use crate::proto::{AeadAlgorithm, CiphertextEnvelope, KeyMaterial, KeyType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyHandle {
pub key_id: String,
pub public_key: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportEntry {
pub key_id: String,
pub key_type: KeyType,
pub material: KeyMaterial,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SignNatsJwtOptions {
pub expected_type: Option<pb::NatsJwtType>,
pub ttl_secs: Option<u64>,
pub expires_at: Option<u64>,
pub issued_at: Option<u64>,
pub rewrite_jti: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllowedNatsSigner {
KeyId(String),
NatsPublicKey(String),
}
impl AllowedNatsSigner {
#[must_use]
pub fn key_id(key_id: impl Into<String>) -> Self {
Self::KeyId(key_id.into())
}
#[must_use]
pub fn nats_public_key(public_key: impl Into<String>) -> Self {
Self::NatsPublicKey(public_key.into())
}
fn into_proto(self) -> pb::AllowedNatsSigner {
let signer = match self {
Self::KeyId(key_id) => pb::allowed_nats_signer::Signer::KeyId(key_id),
Self::NatsPublicKey(public_key) => {
pb::allowed_nats_signer::Signer::NatsPublicKey(public_key)
}
};
pb::AllowedNatsSigner {
signer: Some(signer),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NatsJwtValidationReason {
Valid,
Malformed,
BadSignature,
UnknownSigner,
Expired,
NotYetValid,
WrongType,
Unknown,
}
impl NatsJwtValidationReason {
const fn from_proto(reason: pb::NatsJwtValidationReason) -> Self {
match reason {
pb::NatsJwtValidationReason::Valid => Self::Valid,
pb::NatsJwtValidationReason::Malformed => Self::Malformed,
pb::NatsJwtValidationReason::BadSignature => Self::BadSignature,
pb::NatsJwtValidationReason::UnknownSigner => Self::UnknownSigner,
pb::NatsJwtValidationReason::Expired => Self::Expired,
pb::NatsJwtValidationReason::NotYetValid => Self::NotYetValid,
pb::NatsJwtValidationReason::WrongType => Self::WrongType,
pb::NatsJwtValidationReason::Unspecified => Self::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NatsJwtValidation {
pub valid: bool,
pub reason: NatsJwtValidationReason,
pub subject: String,
pub issuer: String,
pub matched_signer_key_id: Option<String>,
pub jwt_type: pb::NatsJwtType,
pub expires_at: Option<u64>,
pub issued_at: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NatsUserPermissions {
pub pub_allow: Vec<String>,
pub pub_deny: Vec<String>,
pub sub_allow: Vec<String>,
pub sub_deny: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretValue {
pub value: Vec<u8>,
pub version: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MintedJwt {
pub token: String,
pub expires_at: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssuedCertificate {
pub cert_chain_der: Vec<Vec<u8>>,
pub private_key_der: Vec<u8>,
pub ca_chain_der: Vec<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentStatus {
pub backend: String,
pub version: String,
pub protocol: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentHealth {
pub alive: bool,
pub version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadinessReason {
Ready,
BackendUnreachable,
RequiredKeyMissing,
Unknown,
}
impl ReadinessReason {
const fn from_proto(reason: pb::ReadinessReason) -> Self {
match reason {
pb::ReadinessReason::Ready => Self::Ready,
pb::ReadinessReason::BackendUnreachable => Self::BackendUnreachable,
pb::ReadinessReason::RequiredKeyMissing => Self::RequiredKeyMissing,
pb::ReadinessReason::Unspecified => Self::Unknown,
}
}
}
impl std::fmt::Display for ReadinessReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Ready => "ready",
Self::BackendUnreachable => "backend_unreachable",
Self::RequiredKeyMissing => "required_key_missing",
Self::Unknown => "unknown",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentReadiness {
pub ready: bool,
pub reason: ReadinessReason,
pub generation: u64,
pub keys_total: u32,
pub keys_present: u32,
pub keys_required_missing: u32,
pub keys_optional_missing: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReloadRejection {
pub reason: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentReload {
pub applied: bool,
pub checked: bool,
pub previous_generation: u64,
pub new_generation: u64,
pub key_count: u32,
pub grant_count: u32,
pub rejection: Option<ReloadRejection>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentRevocation {
pub trust_domain: String,
pub jti: String,
pub expires_at_unix: u64,
pub persisted: bool,
}
impl AgentReload {
#[must_use]
pub const fn succeeded(&self) -> bool {
self.rejection.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchedRule {
pub rule: String,
pub via: String,
pub action: String,
pub target: String,
pub subject: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentExplanation {
pub subject: String,
pub op: String,
pub key: String,
pub decision: String,
pub via: String,
pub reason: String,
pub matched_rule: Option<MatchedRule>,
}
#[derive(Clone)]
pub struct Client {
signing: SigningServiceClient<Channel>,
aead: AeadServiceClient<Channel>,
secrets: SecretServiceClient<Channel>,
minting: MintingServiceClient<Channel>,
nats: NatsServiceClient<Channel>,
admin: AdminServiceClient<Channel>,
default_timeout: u64,
}
impl Client {
pub async fn connect(path: &str) -> Result<Self> {
Self::connect_with_timeout(path, DEFAULT_CONN_TIMEOUT).await
}
pub async fn connect_with_timeout(path: &str, default_timeout: u64) -> Result<Self> {
trace!(%path, "connecting to broker gRPC socket");
let channel = uds_channel(path, default_timeout).await?;
Ok(Self {
signing: SigningServiceClient::new(channel.clone()),
aead: AeadServiceClient::new(channel.clone()),
secrets: SecretServiceClient::new(channel.clone()),
minting: MintingServiceClient::new(channel.clone()),
nats: NatsServiceClient::new(channel.clone()),
admin: AdminServiceClient::new(channel),
default_timeout,
})
}
async fn bounded<T>(
timeout_secs: u64,
fut: impl std::future::Future<Output = std::result::Result<T, Status>>,
) -> Result<T> {
timeout(Duration::from_secs(timeout_secs), fut)
.await
.map_err(|_| Error::Timeout)?
.map_err(|status| status_error(&status))
}
pub async fn new_key(&mut self, key_id: &str, key_type: KeyType) -> Result<KeyHandle> {
let response = Self::bounded(
self.default_timeout,
self.signing.new_key(pb::NewKeyRequest {
key_id: key_id.to_string(),
key_type: proto_key_type(key_type),
}),
)
.await?;
let body = response.into_inner();
Ok(KeyHandle {
key_id: body.key_id,
public_key: body.public_key,
})
}
pub async fn import(
&mut self,
key_id: &str,
key_type: KeyType,
material: KeyMaterial,
) -> Result<KeyHandle> {
let response = Self::bounded(
self.default_timeout,
self.signing.import(pb::ImportRequest {
key_id: key_id.to_string(),
key_type: proto_key_type(key_type),
material: Some(proto_key_material(material)),
}),
)
.await?;
let body = response.into_inner();
Ok(KeyHandle {
key_id: body.key_id,
public_key: body.public_key,
})
}
pub async fn import_set(&mut self, entries: Vec<ImportEntry>) -> Result<Vec<KeyHandle>> {
let entries = entries
.into_iter()
.map(|entry| pb::ImportEntry {
key_id: entry.key_id,
key_type: proto_key_type(entry.key_type),
material: Some(proto_key_material(entry.material)),
})
.collect();
let response = Self::bounded(
self.default_timeout,
self.signing.import_set(pb::ImportSetRequest { entries }),
)
.await?;
Ok(response
.into_inner()
.keys
.into_iter()
.map(|key| KeyHandle {
key_id: key.key_id,
public_key: key.public_key,
})
.collect())
}
pub async fn sign(&mut self, key_id: &str, message: &[u8]) -> Result<Vec<u8>> {
self.sign_with_algorithm(key_id, message, pb::SigningAlgorithm::Unspecified)
.await
}
pub async fn sign_with_algorithm(
&mut self,
key_id: &str,
message: &[u8],
algorithm: pb::SigningAlgorithm,
) -> Result<Vec<u8>> {
let response = Self::bounded(
self.default_timeout,
self.signing.sign(pb::SignRequest {
key_id: key_id.to_string(),
message: message.to_vec(),
algorithm: algorithm.into(),
}),
)
.await?;
Ok(response.into_inner().signature)
}
pub async fn verify(&mut self, key_id: &str, message: &[u8], signature: &[u8]) -> Result<bool> {
self.verify_with_algorithm(
key_id,
message,
signature,
pb::SigningAlgorithm::Unspecified,
)
.await
}
pub async fn verify_with_algorithm(
&mut self,
key_id: &str,
message: &[u8],
signature: &[u8],
algorithm: pb::SigningAlgorithm,
) -> Result<bool> {
let response = Self::bounded(
self.default_timeout,
self.signing.verify(pb::VerifyRequest {
key_id: key_id.to_string(),
message: message.to_vec(),
signature: signature.to_vec(),
algorithm: algorithm.into(),
}),
)
.await?;
Ok(response.into_inner().valid)
}
pub async fn get_public_key(
&mut self,
key_id: &str,
version: Option<u32>,
) -> Result<pb::GetPublicKeyResponse> {
let response = Self::bounded(
self.default_timeout,
self.signing.get_public_key(pb::GetPublicKeyRequest {
key_id: key_id.to_string(),
version,
}),
)
.await?;
Ok(response.into_inner())
}
pub async fn encrypt(
&mut self,
key_id: &str,
algorithm: AeadAlgorithm,
plaintext: &[u8],
aad: Option<&[u8]>,
) -> Result<CiphertextEnvelope> {
let response = Self::bounded(
self.default_timeout,
self.aead.encrypt(pb::EncryptRequest {
key_id: key_id.to_string(),
algorithm: proto_aead_algorithm(algorithm),
plaintext: plaintext.to_vec(),
aad: aad.map(<[u8]>::to_vec),
}),
)
.await?;
let envelope = response
.into_inner()
.envelope
.ok_or_else(|| missing_field("encrypt", "envelope"))?;
Ok(basil_ciphertext_envelope(envelope))
}
pub async fn decrypt(
&mut self,
key_id: &str,
envelope: CiphertextEnvelope,
aad: Option<&[u8]>,
) -> Result<Vec<u8>> {
let response = Self::bounded(
self.default_timeout,
self.aead.decrypt(pb::DecryptRequest {
key_id: key_id.to_string(),
envelope: Some(proto_ciphertext_envelope(envelope)),
aad: aad.map(<[u8]>::to_vec),
}),
)
.await?;
Ok(response.into_inner().plaintext)
}
pub async fn wrap_envelope(
&mut self,
key_id: &str,
kem_algorithm: pb::KemAlgorithm,
envelope_algorithm: pb::EnvelopeAlgorithm,
plaintext: &[u8],
aad: Option<&[u8]>,
) -> Result<pb::KemEnvelope> {
let response = Self::bounded(
self.default_timeout,
self.aead.wrap_envelope(pb::WrapEnvelopeRequest {
key_id: key_id.to_string(),
kem_algorithm: kem_algorithm.into(),
envelope_algorithm: envelope_algorithm.into(),
plaintext: plaintext.to_vec(),
aad: aad.map(<[u8]>::to_vec),
}),
)
.await?;
response
.into_inner()
.envelope
.ok_or_else(|| missing_field("wrap_envelope", "envelope"))
}
pub async fn unwrap_envelope(
&mut self,
key_id: &str,
envelope: pb::KemEnvelope,
aad: Option<&[u8]>,
) -> Result<Vec<u8>> {
let response = Self::bounded(
self.default_timeout,
self.aead.unwrap_envelope(pb::UnwrapEnvelopeRequest {
key_id: key_id.to_string(),
envelope: Some(envelope),
aad: aad.map(<[u8]>::to_vec),
}),
)
.await?;
Ok(response.into_inner().plaintext)
}
pub async fn unseal_cose(
&mut self,
key_id: &str,
cose_encrypt: &[u8],
external_aad: Option<&[u8]>,
) -> Result<Vec<u8>> {
let response = Self::bounded(
self.default_timeout,
self.aead.unseal_cose(pb::UnsealCoseRequest {
key_id: key_id.to_string(),
cose_encrypt: cose_encrypt.to_vec(),
external_aad: external_aad.map(<[u8]>::to_vec),
}),
)
.await?;
Ok(response.into_inner().plaintext)
}
pub async fn get_secret(
&mut self,
secret_id: &str,
version: Option<u32>,
) -> Result<SecretValue> {
let response = Self::bounded(
self.default_timeout,
self.secrets.get_secret(pb::GetSecretRequest {
secret_id: secret_id.to_string(),
version,
}),
)
.await?;
let mut body = response.into_inner();
Ok(SecretValue {
value: std::mem::take(&mut body.value),
version: body.version,
})
}
pub async fn set_secret(&mut self, secret_id: &str, value: &[u8]) -> Result<u32> {
let response = Self::bounded(
self.default_timeout,
self.secrets.set_secret(pb::SetSecretRequest {
secret_id: secret_id.to_string(),
value: value.to_vec(),
}),
)
.await?;
Ok(response.into_inner().version)
}
pub async fn rotate_secret(&mut self, secret_id: &str) -> Result<u32> {
let response = Self::bounded(
self.default_timeout,
self.secrets.rotate_secret(pb::RotateSecretRequest {
secret_id: secret_id.to_string(),
}),
)
.await?;
Ok(response.into_inner().version)
}
pub async fn list_catalog(&mut self, prefix: Option<&str>) -> Result<Vec<pb::CatalogEntry>> {
let response = Self::bounded(
self.default_timeout,
self.secrets.list_catalog(pb::ListCatalogRequest {
prefix: prefix.map(ToString::to_string),
}),
)
.await?;
let mut stream = response.into_inner();
let mut keys = Vec::new();
loop {
match Self::bounded(self.default_timeout, stream.message()).await? {
Some(key) => keys.push(key),
None => return Ok(keys),
}
}
}
pub async fn mint_jwt(
&mut self,
key_id: &str,
sub: &str,
ttl_secs: Option<u64>,
claims: serde_json::Value,
) -> Result<MintedJwt> {
let response = Self::bounded(
self.default_timeout,
self.minting.mint_jwt(pb::MintJwtRequest {
key_id: key_id.to_string(),
subject: Some(sub.to_string()),
ttl: ttl_secs.map(proto_duration),
claims: Some(json_struct(claims)),
}),
)
.await?;
let body = response.into_inner();
Ok(MintedJwt {
token: body.token,
expires_at: body.expires_at.map(timestamp_secs),
})
}
pub async fn mint_nats_user(
&mut self,
key_id: &str,
subject_user_nkey: &str,
issuer_account: Option<&str>,
name: &str,
ttl_secs: Option<u64>,
permissions: NatsUserPermissions,
) -> Result<String> {
let response = Self::bounded(
self.default_timeout,
self.nats.mint_nats_user(pb::MintNatsUserRequest {
key_id: key_id.to_string(),
subject_user_nkey: subject_user_nkey.to_string(),
issuer_account: issuer_account.map(str::to_string),
name: name.to_string(),
ttl: ttl_secs.map(proto_duration),
pub_allow: permissions.pub_allow,
pub_deny: permissions.pub_deny,
sub_allow: permissions.sub_allow,
sub_deny: permissions.sub_deny,
}),
)
.await?;
Ok(response.into_inner().token)
}
pub async fn mint_nats_account(
&mut self,
signing_key_id: &str,
subject_account_nkey: &str,
name: &str,
signing_keys: &[String],
expires_in_secs: Option<u64>,
) -> Result<String> {
let response = Self::bounded(
self.default_timeout,
self.nats.mint_nats_account(pb::MintNatsAccountRequest {
key_id: signing_key_id.to_string(),
subject_account_nkey: subject_account_nkey.to_string(),
name: name.to_string(),
ttl: expires_in_secs.map(proto_duration),
signing_keys: signing_keys.to_vec(),
}),
)
.await?;
Ok(response.into_inner().token)
}
#[allow(clippy::too_many_arguments)]
pub async fn mint_nats_operator(
&mut self,
signing_key_id: &str,
subject_operator_nkey: Option<&str>,
name: &str,
signing_keys: &[String],
account_server_url: Option<&str>,
system_account: Option<&str>,
expires_in_secs: Option<u64>,
) -> Result<String> {
let response = Self::bounded(
self.default_timeout,
self.nats.mint_nats_operator(pb::MintNatsOperatorRequest {
key_id: signing_key_id.to_string(),
subject_operator_nkey: subject_operator_nkey.map(ToString::to_string),
name: name.to_string(),
ttl: expires_in_secs.map(proto_duration),
signing_keys: signing_keys.to_vec(),
account_server_url: account_server_url.map(ToString::to_string),
system_account: system_account.map(ToString::to_string),
}),
)
.await?;
Ok(response.into_inner().token)
}
pub async fn mint_nats_signer(
&mut self,
signing_key_id: &str,
subject_nkey: &str,
name: &str,
expires_in_secs: Option<u64>,
) -> Result<String> {
let response = Self::bounded(
self.default_timeout,
self.nats.mint_nats_signer(pb::MintNatsSignerRequest {
key_id: signing_key_id.to_string(),
subject_nkey: subject_nkey.to_string(),
name: name.to_string(),
ttl: expires_in_secs.map(proto_duration),
}),
)
.await?;
Ok(response.into_inner().token)
}
pub async fn mint_nats_server(
&mut self,
signing_key_id: &str,
subject_server_nkey: &str,
name: &str,
expires_in_secs: Option<u64>,
) -> Result<String> {
let response = Self::bounded(
self.default_timeout,
self.nats.mint_nats_server(pb::MintNatsServerRequest {
key_id: signing_key_id.to_string(),
subject_server_nkey: subject_server_nkey.to_string(),
name: name.to_string(),
ttl: expires_in_secs.map(proto_duration),
}),
)
.await?;
Ok(response.into_inner().token)
}
pub async fn mint_nats_curve(
&mut self,
signing_key_id: &str,
subject_curve_nkey: &str,
name: &str,
expires_in_secs: Option<u64>,
) -> Result<String> {
let response = Self::bounded(
self.default_timeout,
self.nats.mint_nats_curve(pb::MintNatsCurveRequest {
key_id: signing_key_id.to_string(),
subject_curve_nkey: subject_curve_nkey.to_string(),
name: name.to_string(),
ttl: expires_in_secs.map(proto_duration),
}),
)
.await?;
Ok(response.into_inner().token)
}
pub async fn encrypt_nats_curve(
&mut self,
key_id: &str,
recipient_public_xkey: &str,
plaintext: &[u8],
) -> Result<Vec<u8>> {
let response = Self::bounded(
self.default_timeout,
self.nats.encrypt_nats_curve(pb::EncryptNatsCurveRequest {
key_id: key_id.to_string(),
recipient_public_xkey: recipient_public_xkey.to_string(),
plaintext: plaintext.to_vec(),
}),
)
.await?;
Ok(response.into_inner().ciphertext)
}
pub async fn decrypt_nats_curve(
&mut self,
key_id: &str,
sender_public_xkey: &str,
ciphertext: &[u8],
) -> Result<Vec<u8>> {
let response = Self::bounded(
self.default_timeout,
self.nats.decrypt_nats_curve(pb::DecryptNatsCurveRequest {
key_id: key_id.to_string(),
sender_public_xkey: sender_public_xkey.to_string(),
ciphertext: ciphertext.to_vec(),
}),
)
.await?;
Ok(response.into_inner().plaintext)
}
pub async fn sign_nats_jwt(
&mut self,
key_id: &str,
claims: impl serde::Serialize,
options: SignNatsJwtOptions,
) -> Result<MintedJwt> {
let claims_json = serde_json::to_vec(&claims)?;
self.sign_nats_jwt_json(key_id, claims_json, options).await
}
pub async fn sign_nats_jwt_json(
&mut self,
key_id: &str,
claims_json: impl Into<Vec<u8>>,
options: SignNatsJwtOptions,
) -> Result<MintedJwt> {
let response = Self::bounded(
self.default_timeout,
self.nats.sign_nats_jwt(pb::SignNatsJwtRequest {
key_id: key_id.to_string(),
claims_json: claims_json.into(),
expected_type: options.expected_type.unwrap_or_default().into(),
ttl: options.ttl_secs.map(proto_duration),
expires_at: options.expires_at.map(proto_timestamp),
issued_at: options.issued_at.map(proto_timestamp),
jti_mode: if options.rewrite_jti {
pb::NatsJtiMode::Rewrite.into()
} else {
pb::NatsJtiMode::RequireValid.into()
},
}),
)
.await?;
let body = response.into_inner();
Ok(MintedJwt {
token: body.token,
expires_at: body.expires_at.map(timestamp_secs),
})
}
pub async fn validate_nats_jwt(
&mut self,
jwt: &str,
allowed_signers: impl IntoIterator<Item = AllowedNatsSigner>,
expected_type: Option<pb::NatsJwtType>,
) -> Result<NatsJwtValidation> {
let response = Self::bounded(
self.default_timeout,
self.nats.validate_nats_jwt(pb::ValidateNatsJwtRequest {
jwt: jwt.to_string(),
allowed_signers: allowed_signers
.into_iter()
.map(AllowedNatsSigner::into_proto)
.collect(),
expected_type: expected_type.unwrap_or_default().into(),
}),
)
.await?;
let body = response.into_inner();
let reason = NatsJwtValidationReason::from_proto(body.reason());
let jwt_type = body.jwt_type();
Ok(NatsJwtValidation {
valid: body.valid,
reason,
subject: body.subject,
issuer: body.issuer,
jwt_type,
matched_signer_key_id: non_empty(body.matched_signer_key_id),
expires_at: non_zero(body.expires_at_unix),
issued_at: non_zero(body.issued_at_unix),
})
}
pub async fn issue_certificate(
&mut self,
issuer_key_id: &str,
common_name: &str,
dns_sans: &[String],
ip_sans: &[String],
ttl_secs: u64,
) -> Result<IssuedCertificate> {
let response = Self::bounded(
self.default_timeout,
self.minting.issue_certificate(pb::IssueCertificateRequest {
issuer_key_id: issuer_key_id.to_string(),
common_name: common_name.to_string(),
dns_sans: dns_sans.to_vec(),
ip_sans: ip_sans.to_vec(),
ttl: Some(proto_duration(ttl_secs)),
}),
)
.await?;
let mut body = response.into_inner();
Ok(IssuedCertificate {
cert_chain_der: std::mem::take(&mut body.cert_chain_der),
private_key_der: std::mem::take(&mut body.private_key_der),
ca_chain_der: std::mem::take(&mut body.ca_chain_der),
})
}
pub async fn status(&mut self) -> Result<AgentStatus> {
let response = Self::bounded(
self.default_timeout,
self.admin.status(pb::StatusRequest {}),
)
.await?;
let body = response.into_inner();
Ok(AgentStatus {
backend: body.backend,
version: body.version,
protocol: body.protocol,
})
}
pub async fn health(&mut self) -> Result<AgentHealth> {
let response = Self::bounded(
self.default_timeout,
self.admin.health(pb::HealthRequest {}),
)
.await?;
let body = response.into_inner();
Ok(AgentHealth {
alive: body.alive,
version: body.version,
})
}
pub async fn readiness(&mut self) -> Result<AgentReadiness> {
let response = Self::bounded(
self.default_timeout,
self.admin.readiness(pb::ReadinessRequest {}),
)
.await?;
let body = response.into_inner();
Ok(AgentReadiness {
ready: body.ready,
reason: ReadinessReason::from_proto(body.reason()),
generation: body.generation,
keys_total: body.keys_total,
keys_present: body.keys_present,
keys_required_missing: body.keys_required_missing,
keys_optional_missing: body.keys_optional_missing,
})
}
pub async fn reload(&mut self, check: bool) -> Result<AgentReload> {
let response = Self::bounded(
self.default_timeout,
self.admin.reload(pb::ReloadRequest { check }),
)
.await?;
let body = response.into_inner();
Ok(AgentReload {
applied: body.applied,
checked: body.checked,
previous_generation: body.previous_generation,
new_generation: body.new_generation,
key_count: body.key_count,
grant_count: body.grant_count,
rejection: body.rejection.map(|r| ReloadRejection {
reason: r.reason,
message: r.message,
}),
})
}
pub async fn explain(
&mut self,
subject: &str,
op: &str,
key: &str,
) -> Result<AgentExplanation> {
let response = Self::bounded(
self.default_timeout,
self.admin.explain(pb::ExplainRequest {
subject: subject.to_string(),
op: op.to_string(),
key: key.to_string(),
}),
)
.await?;
let body = response.into_inner();
Ok(AgentExplanation {
subject: body.subject,
op: body.op,
key: body.key,
decision: body.decision,
via: body.via,
reason: body.reason,
matched_rule: body.matched_rule.map(|m| MatchedRule {
rule: m.rule,
via: m.via,
action: m.action,
target: m.target,
subject: m.subject,
}),
})
}
pub async fn revoke(
&mut self,
trust_domain: &str,
jti: &str,
expires_at_unix: u64,
) -> Result<AgentRevocation> {
let response = Self::bounded(
self.default_timeout,
self.admin.revoke(pb::RevokeRequest {
trust_domain: trust_domain.to_string(),
jti: jti.to_string(),
expires_at_unix,
}),
)
.await?;
let body = response.into_inner();
Ok(AgentRevocation {
trust_domain: body.trust_domain,
jti: body.jti,
expires_at_unix: body.expires_at_unix,
persisted: body.persisted,
})
}
}
async fn uds_channel(path: &str, timeout_secs: u64) -> Result<Channel> {
let path = path.to_string();
let endpoint =
Endpoint::try_from("http://[::]:50051")?.connect_timeout(Duration::from_secs(timeout_secs));
endpoint
.connect_with_connector(service_fn(move |_: Uri| {
let path = path.clone();
async move {
UnixStream::connect(path)
.await
.map(TokioIo::new)
.inspect_err(|e| error!(?e, "unix socket connect failed"))
}
}))
.await
.map_err(Error::Endpoint)
}
fn status_error(status: &Status) -> Error {
let (reason, op) = broker_error_info(status).unwrap_or_else(|| (String::new(), String::new()));
Error::Status {
code: status.code(),
reason,
op,
message: status.message().to_string(),
}
}
fn broker_error_info(status: &Status) -> Option<(String, String)> {
let rpc_status = basil_proto::google::rpc::Status::decode(status.details()).ok()?;
rpc_status.details.into_iter().find_map(|detail| {
if detail.type_url == "type.googleapis.com/basil.broker.v1.BrokerErrorInfo" {
pb::BrokerErrorInfo::decode(detail.value.as_slice())
.ok()
.map(|info| (info.reason, info.op))
} else {
None
}
})
}
fn missing_field(op: &'static str, field: &'static str) -> Error {
Error::Status {
code: tonic::Code::Internal,
reason: "MISSING_FIELD".to_string(),
op: op.to_string(),
message: format!("broker response omitted {field}"),
}
}
fn proto_key_type(value: KeyType) -> i32 {
match value {
KeyType::Ed25519 => pb::KeyType::Ed25519,
KeyType::Ed25519Nkey => pb::KeyType::Ed25519Nkey,
KeyType::Rsa2048 => pb::KeyType::Rsa2048,
KeyType::EcdsaP256 => pb::KeyType::EcdsaP256,
KeyType::EcdsaP384 => pb::KeyType::EcdsaP384,
KeyType::EcdsaP521 => pb::KeyType::EcdsaP521,
KeyType::MlDsa44 => pb::KeyType::MlDsa44,
KeyType::MlDsa65 => pb::KeyType::MlDsa65,
KeyType::MlDsa87 => pb::KeyType::MlDsa87,
KeyType::MlKem512 => pb::KeyType::MlKem512,
KeyType::MlKem768 => pb::KeyType::MlKem768,
KeyType::MlKem1024 => pb::KeyType::MlKem1024,
}
.into()
}
fn proto_aead_algorithm(value: AeadAlgorithm) -> i32 {
match value {
AeadAlgorithm::Chacha20Poly1305 => pb::AeadAlgorithm::Chacha20Poly1305,
AeadAlgorithm::Aes256Gcm => pb::AeadAlgorithm::Aes256Gcm,
}
.into()
}
fn basil_aead_algorithm(value: i32) -> AeadAlgorithm {
match pb::AeadAlgorithm::try_from(value).unwrap_or(pb::AeadAlgorithm::Aes256Gcm) {
pb::AeadAlgorithm::Chacha20Poly1305 => AeadAlgorithm::Chacha20Poly1305,
pb::AeadAlgorithm::Unspecified | pb::AeadAlgorithm::Aes256Gcm => AeadAlgorithm::Aes256Gcm,
}
}
fn proto_key_material(mut value: KeyMaterial) -> pb::KeyMaterial {
let material = match &mut value {
KeyMaterial::Ed25519Seed(seed) => {
pb::key_material::Material::Ed25519Seed(std::mem::take(seed))
}
KeyMaterial::Pkcs8Der(der) => pb::key_material::Material::Pkcs8Der(std::mem::take(der)),
};
pb::KeyMaterial {
material: Some(material),
}
}
fn proto_ciphertext_envelope(value: CiphertextEnvelope) -> pb::CiphertextEnvelope {
pb::CiphertextEnvelope {
alg: proto_aead_algorithm(value.alg),
key_version: value.key_version,
nonce: value.nonce,
ciphertext: value.ciphertext,
}
}
fn basil_ciphertext_envelope(value: pb::CiphertextEnvelope) -> CiphertextEnvelope {
CiphertextEnvelope {
alg: basil_aead_algorithm(value.alg),
key_version: value.key_version,
nonce: value.nonce,
ciphertext: value.ciphertext,
}
}
fn proto_duration(secs: u64) -> ProtoDuration {
ProtoDuration {
seconds: i64::try_from(secs).unwrap_or(i64::MAX),
nanos: 0,
}
}
fn proto_timestamp(secs: u64) -> Timestamp {
Timestamp {
seconds: i64::try_from(secs).unwrap_or(i64::MAX),
nanos: 0,
}
}
fn timestamp_secs(value: Timestamp) -> u64 {
u64::try_from(value.seconds).unwrap_or(0)
}
fn non_empty(value: String) -> Option<String> {
(!value.is_empty()).then_some(value)
}
fn non_zero(value: u64) -> Option<u64> {
(value != 0).then_some(value)
}
fn json_struct(value: serde_json::Value) -> Struct {
let serde_json::Value::Object(fields) = value else {
return Struct::default();
};
Struct {
fields: fields
.into_iter()
.map(|(key, value)| (key, json_value(value)))
.collect(),
}
}
fn json_value(value: serde_json::Value) -> ProtoValue {
let kind = match value {
serde_json::Value::Null => prost_types::value::Kind::NullValue(0),
serde_json::Value::Bool(value) => prost_types::value::Kind::BoolValue(value),
serde_json::Value::Number(value) => {
prost_types::value::Kind::NumberValue(value.as_f64().unwrap_or(0.0))
}
serde_json::Value::String(value) => prost_types::value::Kind::StringValue(value),
serde_json::Value::Array(values) => {
prost_types::value::Kind::ListValue(prost_types::ListValue {
values: values.into_iter().map(json_value).collect(),
})
}
serde_json::Value::Object(_) => prost_types::value::Kind::StructValue(json_struct(value)),
};
ProtoValue { kind: Some(kind) }
}
#[cfg(test)]
mod nats_client_tests {
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::net::UnixListener;
use tokio::sync::oneshot;
use tokio_stream::wrappers::UnixListenerStream;
use tonic::transport::Server;
use tonic::{Request, Response, Status};
use super::*;
use basil_proto::broker::v1::nats_service_server::{NatsService, NatsServiceServer};
#[derive(Clone, Default)]
struct FakeNatsService {
last_sign: Arc<Mutex<Option<pb::SignNatsJwtRequest>>>,
last_validate: Arc<Mutex<Option<pb::ValidateNatsJwtRequest>>>,
}
#[tonic::async_trait]
impl NatsService for FakeNatsService {
async fn mint_nats_user(
&self,
_request: Request<pb::MintNatsUserRequest>,
) -> std::result::Result<Response<pb::CredentialResponse>, Status> {
Err(Status::unimplemented("mint_nats_user"))
}
async fn mint_nats_account(
&self,
_request: Request<pb::MintNatsAccountRequest>,
) -> std::result::Result<Response<pb::CredentialResponse>, Status> {
Err(Status::unimplemented("mint_nats_account"))
}
async fn mint_nats_operator(
&self,
_request: Request<pb::MintNatsOperatorRequest>,
) -> std::result::Result<Response<pb::CredentialResponse>, Status> {
Err(Status::unimplemented("mint_nats_operator"))
}
async fn mint_nats_signer(
&self,
_request: Request<pb::MintNatsSignerRequest>,
) -> std::result::Result<Response<pb::CredentialResponse>, Status> {
Err(Status::unimplemented("mint_nats_signer"))
}
async fn mint_nats_server(
&self,
_request: Request<pb::MintNatsServerRequest>,
) -> std::result::Result<Response<pb::CredentialResponse>, Status> {
Err(Status::unimplemented("mint_nats_server"))
}
async fn mint_nats_curve(
&self,
_request: Request<pb::MintNatsCurveRequest>,
) -> std::result::Result<Response<pb::CredentialResponse>, Status> {
Err(Status::unimplemented("mint_nats_curve"))
}
async fn encrypt_nats_curve(
&self,
_request: Request<pb::EncryptNatsCurveRequest>,
) -> std::result::Result<Response<pb::EncryptNatsCurveResponse>, Status> {
Err(Status::unimplemented("encrypt_nats_curve"))
}
async fn decrypt_nats_curve(
&self,
_request: Request<pb::DecryptNatsCurveRequest>,
) -> std::result::Result<Response<pb::DecryptNatsCurveResponse>, Status> {
Err(Status::unimplemented("decrypt_nats_curve"))
}
async fn sign_nats_jwt(
&self,
request: Request<pb::SignNatsJwtRequest>,
) -> std::result::Result<Response<pb::CredentialResponse>, Status> {
*self.last_sign.lock().expect("request lock") = Some(request.into_inner());
Ok(Response::new(pb::CredentialResponse {
token: "nats.jwt".to_string(),
expires_at: None,
}))
}
async fn validate_nats_jwt(
&self,
request: Request<pb::ValidateNatsJwtRequest>,
) -> std::result::Result<Response<pb::ValidateNatsJwtResponse>, Status> {
*self.last_validate.lock().expect("request lock") = Some(request.into_inner());
Ok(Response::new(pb::ValidateNatsJwtResponse {
valid: true,
reason: pb::NatsJwtValidationReason::Valid.into(),
subject: "UDVCJ4FZLS".to_string(),
issuer: "ADVCJ4FZLS".to_string(),
matched_signer_key_id: "issuer.account".to_string(),
jwt_type: pb::NatsJwtType::User.into(),
expires_at_unix: 42,
issued_at_unix: 7,
}))
}
}
#[tokio::test]
async fn sign_nats_jwt_sends_raw_json_claims() {
let service = FakeNatsService::default();
let last_sign = Arc::clone(&service.last_sign);
let path = unique_socket_path();
let listener = UnixListener::bind(&path).expect("bind unix socket");
let incoming = UnixListenerStream::new(listener);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let server = tokio::spawn(async move {
Server::builder()
.add_service(NatsServiceServer::new(service))
.serve_with_incoming_shutdown(incoming, async {
let _ = shutdown_rx.await;
})
.await
});
let claims = serde_json::json!({
"sub": "UABC",
"iat": 9_007_199_254_740_993_u64,
"nats": { "type": "user", "version": 2 }
});
let response = {
let mut client = Client::connect(path.to_str().expect("socket path"))
.await
.expect("connect");
client
.sign_nats_jwt(
"issuer.account",
claims,
SignNatsJwtOptions {
expected_type: Some(pb::NatsJwtType::User),
rewrite_jti: true,
..SignNatsJwtOptions::default()
},
)
.await
.expect("sign")
};
assert_eq!(response.token, "nats.jwt");
let request = last_sign
.lock()
.expect("request lock")
.clone()
.expect("sign request");
assert_eq!(request.key_id, "issuer.account");
assert_eq!(request.expected_type, i32::from(pb::NatsJwtType::User));
assert_eq!(request.jti_mode, i32::from(pb::NatsJtiMode::Rewrite));
let claims: serde_json::Value =
serde_json::from_slice(&request.claims_json).expect("claims json");
assert_eq!(claims["iat"], 9_007_199_254_740_993_u64);
shutdown_tx.send(()).expect("shutdown server");
server.await.expect("join server").expect("server");
std::fs::remove_file(path).expect("remove socket");
}
#[tokio::test]
async fn sign_nats_jwt_json_sends_preencoded_claim_bytes() {
let service = FakeNatsService::default();
let last_sign = Arc::clone(&service.last_sign);
let path = unique_socket_path();
let listener = UnixListener::bind(&path).expect("bind unix socket");
let incoming = UnixListenerStream::new(listener);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let server = tokio::spawn(async move {
Server::builder()
.add_service(NatsServiceServer::new(service))
.serve_with_incoming_shutdown(incoming, async {
let _ = shutdown_rx.await;
})
.await
});
let claims_json =
br#"{"sub":"UABC","iat":9007199254740993,"nats":{"type":"user","version":2}}"#;
let response = {
let mut client = Client::connect(path.to_str().expect("socket path"))
.await
.expect("connect");
client
.sign_nats_jwt_json(
"issuer.account",
claims_json.to_vec(),
SignNatsJwtOptions {
expected_type: Some(pb::NatsJwtType::User),
..SignNatsJwtOptions::default()
},
)
.await
.expect("sign")
};
assert_eq!(response.token, "nats.jwt");
let request = last_sign
.lock()
.expect("request lock")
.clone()
.expect("sign request");
assert_eq!(request.claims_json, claims_json);
assert_eq!(request.expected_type, i32::from(pb::NatsJwtType::User));
shutdown_tx.send(()).expect("shutdown server");
server.await.expect("join server").expect("server");
std::fs::remove_file(path).expect("remove socket");
}
#[tokio::test]
async fn validate_nats_jwt_builds_request_and_maps_response() {
let service = FakeNatsService::default();
let last_validate = Arc::clone(&service.last_validate);
let path = unique_socket_path();
let listener = UnixListener::bind(&path).expect("bind unix socket");
let incoming = UnixListenerStream::new(listener);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let server = tokio::spawn(async move {
Server::builder()
.add_service(NatsServiceServer::new(service))
.serve_with_incoming_shutdown(incoming, async {
let _ = shutdown_rx.await;
})
.await
});
let response = {
let mut client = Client::connect(path.to_str().expect("socket path"))
.await
.expect("connect");
client
.validate_nats_jwt(
"header.body.sig",
[
AllowedNatsSigner::key_id("issuer.account"),
AllowedNatsSigner::nats_public_key("ADVCJ4FZLS"),
],
Some(pb::NatsJwtType::User),
)
.await
.expect("validate")
};
assert!(response.valid);
assert_eq!(response.reason, NatsJwtValidationReason::Valid);
assert_eq!(response.subject, "UDVCJ4FZLS");
assert_eq!(response.issuer, "ADVCJ4FZLS");
assert_eq!(
response.matched_signer_key_id.as_deref(),
Some("issuer.account")
);
assert_eq!(response.jwt_type, pb::NatsJwtType::User);
assert_eq!(response.expires_at, Some(42));
assert_eq!(response.issued_at, Some(7));
let request = last_validate
.lock()
.expect("request lock")
.clone()
.expect("validate request");
assert_eq!(request.jwt, "header.body.sig");
assert_eq!(request.expected_type, i32::from(pb::NatsJwtType::User));
assert_eq!(
request.allowed_signers[0].signer,
Some(pb::allowed_nats_signer::Signer::KeyId(
"issuer.account".to_string()
))
);
assert_eq!(
request.allowed_signers[1].signer,
Some(pb::allowed_nats_signer::Signer::NatsPublicKey(
"ADVCJ4FZLS".to_string()
))
);
shutdown_tx.send(()).expect("shutdown server");
server.await.expect("join server").expect("server");
std::fs::remove_file(path).expect("remove socket");
}
fn unique_socket_path() -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
std::env::temp_dir().join(format!("basil-client-{nanos}.sock"))
}
}
#[cfg(test)]
mod tests {
use super::{broker_error_info, json_struct, status_error};
use basil_proto::broker::v1::BrokerErrorInfo;
use prost::Message;
use tonic::Code;
#[test]
fn status_error_extracts_broker_reason() {
let info = BrokerErrorInfo {
reason: "UNAUTHORIZED".into(),
op: "sign".into(),
};
let detail = prost_types::Any {
type_url: "type.googleapis.com/basil.broker.v1.BrokerErrorInfo".into(),
value: info.encode_to_vec(),
};
let rpc_status = basil_proto::google::rpc::Status {
code: Code::PermissionDenied as i32,
message: "not authorized".into(),
details: vec![detail],
};
let status = tonic::Status::with_details(
Code::PermissionDenied,
"not authorized",
rpc_status.encode_to_vec().into(),
);
assert_eq!(
broker_error_info(&status),
Some(("UNAUTHORIZED".into(), "sign".into()))
);
let error = status_error(&status);
match error {
crate::Error::Status {
code, reason, op, ..
} => {
assert_eq!(code, Code::PermissionDenied);
assert_eq!(reason, "UNAUTHORIZED");
assert_eq!(op, "sign");
}
other => assert!(matches!(
other,
crate::Error::Status {
code: Code::PermissionDenied,
..
}
)),
}
}
#[test]
fn json_struct_ignores_non_object_root() {
assert!(
json_struct(serde_json::json!("not-an-object"))
.fields
.is_empty()
);
}
#[test]
fn proto_key_type_maps_post_quantum_families() {
use super::proto_key_type;
use crate::proto::KeyType;
use basil_proto::broker::v1 as pb;
for (domain, wire) in [
(KeyType::Ed25519, pb::KeyType::Ed25519),
(KeyType::MlDsa44, pb::KeyType::MlDsa44),
(KeyType::MlDsa65, pb::KeyType::MlDsa65),
(KeyType::MlDsa87, pb::KeyType::MlDsa87),
(KeyType::MlKem512, pb::KeyType::MlKem512),
(KeyType::MlKem768, pb::KeyType::MlKem768),
(KeyType::MlKem1024, pb::KeyType::MlKem1024),
] {
assert_eq!(proto_key_type(domain), wire as i32);
}
}
}