use std::collections::BTreeMap;
use basil_proto::KeyType;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Catalog {
pub schema_version: u32,
pub backends: BTreeMap<String, BackendRef>,
pub keys: BTreeMap<String, KeyEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BackendRef {
pub kind: BackendKind,
pub addr: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub engines: Vec<Engine>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<Capability>,
#[serde(
default,
rename = "mintKeyTypes",
skip_serializing_if = "Vec::is_empty"
)]
pub mint_key_types: Vec<KeyAlgorithm>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<Capability>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum BackendKind {
#[serde(rename = "vault")]
Vault,
#[serde(rename = "keystore")]
Keystore,
#[serde(rename = "aws-kms")]
AwsKms,
#[serde(rename = "gcp-kms")]
GcpKms,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Class {
Asymmetric,
Symmetric,
Value,
Public,
Sealing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Engine {
Transit,
Kv2,
Pki,
}
impl Engine {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::Transit => "transit",
Self::Kv2 => "kv2",
Self::Pki => "pki",
}
}
}
impl std::fmt::Display for Engine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.token())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(from = "String", into = "String")]
pub enum Capability {
ByokImport,
PrehashSign,
PqcTransit,
PkiCrl,
JwtAuth,
ApproleAuth,
Other(String),
}
impl Capability {
#[must_use]
pub const fn token(&self) -> &str {
match self {
Self::ByokImport => "byok-import",
Self::PrehashSign => "prehash-sign",
Self::PqcTransit => "pqc-transit",
Self::PkiCrl => "pki-crl",
Self::JwtAuth => "jwt-auth",
Self::ApproleAuth => "approle-auth",
Self::Other(s) => s.as_str(),
}
}
#[must_use]
pub const fn is_known(&self) -> bool {
!matches!(self, Self::Other(_))
}
}
impl From<String> for Capability {
fn from(s: String) -> Self {
match s.as_str() {
"byok-import" => Self::ByokImport,
"prehash-sign" => Self::PrehashSign,
"pqc-transit" => Self::PqcTransit,
"pki-crl" => Self::PkiCrl,
"jwt-auth" => Self::JwtAuth,
"approle-auth" => Self::ApproleAuth,
_ => Self::Other(s),
}
}
}
impl From<Capability> for String {
fn from(c: Capability) -> Self {
c.token().to_string()
}
}
impl std::fmt::Display for Capability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.token())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
pub enum KeyAlgorithm {
#[serde(rename = "ed25519")]
Ed25519,
#[serde(rename = "ed25519-nkey")]
Ed25519Nkey,
#[serde(rename = "rsa-2048")]
Rsa2048,
#[serde(rename = "ecdsa-p256")]
EcdsaP256,
#[serde(rename = "ecdsa-p384")]
EcdsaP384,
#[serde(rename = "ecdsa-p521")]
EcdsaP521,
#[serde(rename = "aes-256-gcm")]
Aes256Gcm,
#[serde(rename = "chacha20-poly1305")]
ChaCha20Poly1305,
#[serde(rename = "x25519")]
X25519,
#[serde(rename = "ml-kem-512")]
MlKem512,
#[serde(rename = "ml-kem-768")]
MlKem768,
#[serde(rename = "ml-kem-1024")]
MlKem1024,
#[serde(rename = "ml-dsa-44")]
MlDsa44,
#[serde(rename = "ml-dsa-65")]
MlDsa65,
#[serde(rename = "ml-dsa-87")]
MlDsa87,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum MissingPolicy {
#[default]
Error,
Warn,
Generate,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SealingPin {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parties: Option<PinnedParties>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub external_aad: Vec<String>,
}
impl SealingPin {
#[must_use]
pub const fn is_configured(&self) -> bool {
self.parties.is_some() || !self.external_aad.is_empty()
}
#[must_use]
pub fn external_aad_allowed(&self, aad: &[u8]) -> bool {
self.external_aad.is_empty()
|| self
.external_aad
.iter()
.any(|allowed| allowed.as_bytes() == aad)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PinnedParties {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub party_u: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub party_v: Option<String>,
}
impl PinnedParties {
pub fn to_kdf_parties(&self) -> Result<basil_cose::KdfParties, basil_cose::ProfileError> {
Ok(basil_cose::KdfParties {
party_u: Self::slot(self.party_u.as_deref())?,
party_v: Self::slot(self.party_v.as_deref())?,
})
}
fn slot(identity: Option<&str>) -> Result<basil_cose::PartyIdentity, basil_cose::ProfileError> {
identity.map_or_else(
|| Ok(basil_cose::PartyIdentity::nil()),
|id| basil_cose::PartyIdentity::from_bytes(id.as_bytes().to_vec()),
)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct KeyEntry {
pub class: Class,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_type: Option<KeyAlgorithm>,
pub backend: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine: Option<Engine>,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_path: Option<String>,
pub writable: bool,
#[serde(default)]
pub missing: MissingPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generate: Option<GenerateSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sealing_pin: Option<SealingPin>,
#[serde(default, skip_serializing_if = "Labels::is_empty")]
pub labels: Labels,
pub description: String,
}
impl KeyEntry {
#[must_use]
pub fn effective_engine(&self) -> Engine {
self.engine.unwrap_or(match self.class {
Class::Asymmetric | Class::Symmetric => Engine::Transit,
Class::Value | Class::Public | Class::Sealing => Engine::Kv2,
})
}
#[must_use]
pub fn is_credential_issuer(&self) -> bool {
let nats_issuer = matches!(
self.labels.nats_type(),
Some(basil_nats::NkeyType::Operator | basil_nats::NkeyType::Account)
);
let svid_issuer = matches!(self.labels.get("svid_kind"), Some("jwt" | "x509"));
nats_issuer || svid_issuer
}
#[must_use]
pub fn is_materialize_to_use(&self) -> bool {
match self.class {
Class::Sealing => true,
Class::Asymmetric => self.effective_engine() == Engine::Kv2,
Class::Symmetric | Class::Value | Class::Public => false,
}
}
}
impl KeyAlgorithm {
#[must_use]
pub const fn from_wire_key_type(key_type: KeyType) -> Self {
match key_type {
KeyType::Ed25519 => Self::Ed25519,
KeyType::Ed25519Nkey => Self::Ed25519Nkey,
KeyType::Rsa2048 => Self::Rsa2048,
KeyType::EcdsaP256 => Self::EcdsaP256,
KeyType::EcdsaP384 => Self::EcdsaP384,
KeyType::EcdsaP521 => Self::EcdsaP521,
KeyType::MlDsa44 => Self::MlDsa44,
KeyType::MlDsa65 => Self::MlDsa65,
KeyType::MlDsa87 => Self::MlDsa87,
KeyType::MlKem512 => Self::MlKem512,
KeyType::MlKem768 => Self::MlKem768,
KeyType::MlKem1024 => Self::MlKem1024,
}
}
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::Ed25519 => "ed25519",
Self::Ed25519Nkey => "ed25519-nkey",
Self::Rsa2048 => "rsa-2048",
Self::EcdsaP256 => "ecdsa-p256",
Self::EcdsaP384 => "ecdsa-p384",
Self::EcdsaP521 => "ecdsa-p521",
Self::Aes256Gcm => "aes-256-gcm",
Self::ChaCha20Poly1305 => "chacha20-poly1305",
Self::X25519 => "x25519",
Self::MlKem512 => "ml-kem-512",
Self::MlKem768 => "ml-kem-768",
Self::MlKem1024 => "ml-kem-1024",
Self::MlDsa44 => "ml-dsa-44",
Self::MlDsa65 => "ml-dsa-65",
Self::MlDsa87 => "ml-dsa-87",
}
}
#[must_use]
pub const fn is_spiffe_jwt_svid_profile(self) -> bool {
match self {
Self::Rsa2048 | Self::EcdsaP256 | Self::EcdsaP384 => true,
Self::Ed25519
| Self::Ed25519Nkey
| Self::EcdsaP521
| Self::Aes256Gcm
| Self::ChaCha20Poly1305
| Self::X25519
| Self::MlKem512
| Self::MlKem768
| Self::MlKem1024
| Self::MlDsa44
| Self::MlDsa65
| Self::MlDsa87 => false,
}
}
#[must_use]
pub const fn required_capability(self) -> Option<Capability> {
match self {
Self::Ed25519
| Self::Ed25519Nkey
| Self::Rsa2048
| Self::EcdsaP256
| Self::EcdsaP384
| Self::EcdsaP521
| Self::Aes256Gcm
| Self::ChaCha20Poly1305
| Self::X25519
| Self::MlKem512
| Self::MlKem768
| Self::MlKem1024
| Self::MlDsa44
| Self::MlDsa65
| Self::MlDsa87 => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "format", rename_all = "kebab-case", deny_unknown_fields)]
pub enum GenerateSpec {
AsciiPrintable {
bytes: u32,
},
Base64 {
bytes: u32,
},
Hex {
bytes: u32,
},
AgeX25519,
#[serde(rename_all = "camelCase")]
SelfSignedTls {
common_name: String,
validity: String,
},
#[serde(rename_all = "camelCase")]
SelfSignedTlsPairOf {
pair_of: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(transparent)]
pub struct Labels(pub Vec<String>);
impl Labels {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.0
.iter()
.filter_map(|l| l.split_once('='))
.find_map(|(k, v)| (k == key).then_some(v))
}
#[must_use]
pub fn nats_type(&self) -> Option<basil_nats::NkeyType> {
let value = self.get("nats_type")?;
let mut chars = value.chars();
let letter = chars.next()?;
if chars.next().is_some() {
return None; }
basil_nats::NkeyType::from_letter(letter)
}
#[must_use]
pub fn has_nats_type(&self) -> bool {
self.nats_type().is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels_get_parses_name_value() {
let labels = Labels(vec![
"nats_type=A".into(),
"tier=prod".into(),
"bare".into(),
]);
assert_eq!(labels.get("nats_type"), Some("A"));
assert_eq!(labels.get("tier"), Some("prod"));
assert_eq!(labels.get("bare"), None); assert_eq!(labels.get("absent"), None);
}
#[test]
fn nats_type_returns_single_valid_letter() {
for letter in ['A', 'O', 'U', 'N', 'C', 'X'] {
let labels = Labels(vec![format!("nats_type={letter}")]);
assert_eq!(
labels.nats_type(),
basil_nats::NkeyType::from_letter(letter),
"letter {letter}"
);
assert!(labels.has_nats_type());
}
}
#[test]
fn nats_type_rejects_invalid_or_multichar() {
assert_eq!(Labels(vec!["nats_type=Z".into()]).nats_type(), None);
assert_eq!(Labels(vec!["nats_type=AB".into()]).nats_type(), None);
assert_eq!(Labels(vec!["nats_type=".into()]).nats_type(), None);
assert_eq!(Labels(vec!["nats_type=account".into()]).nats_type(), None);
assert_eq!(Labels(vec![]).nats_type(), None);
assert!(!Labels(vec!["nats_type=Z".into()]).has_nats_type());
}
#[test]
fn enums_deserialize_kebab_case() {
assert_eq!(
serde_json::from_str::<KeyAlgorithm>("\"ed25519-nkey\"").unwrap(),
KeyAlgorithm::Ed25519Nkey
);
assert_eq!(
serde_json::from_str::<KeyAlgorithm>("\"aes-256-gcm\"").unwrap(),
KeyAlgorithm::Aes256Gcm
);
assert_eq!(
serde_json::from_str::<KeyAlgorithm>("\"chacha20-poly1305\"").unwrap(),
KeyAlgorithm::ChaCha20Poly1305
);
assert_eq!(
serde_json::from_str::<Class>("\"public\"").unwrap(),
Class::Public
);
assert_eq!(
serde_json::from_str::<Engine>("\"kv2\"").unwrap(),
Engine::Kv2
);
assert_eq!(
serde_json::from_str::<Engine>("\"pki\"").unwrap(),
Engine::Pki
);
assert_eq!(
serde_json::from_str::<BackendKind>("\"vault\"").unwrap(),
BackendKind::Vault
);
}
#[test]
fn missing_policy_defaults_to_error() {
assert_eq!(MissingPolicy::default(), MissingPolicy::Error);
assert_eq!(
serde_json::from_str::<MissingPolicy>("\"generate\"").unwrap(),
MissingPolicy::Generate
);
}
#[test]
fn generate_spec_is_tagged_by_format() {
let g: GenerateSpec =
serde_json::from_str(r#"{"format":"ascii-printable","bytes":24}"#).unwrap();
assert_eq!(g, GenerateSpec::AsciiPrintable { bytes: 24 });
let g: GenerateSpec = serde_json::from_str(
r#"{"format":"self-signed-tls","commonName":"x","validity":"1h"}"#,
)
.unwrap();
assert_eq!(
g,
GenerateSpec::SelfSignedTls {
common_name: "x".into(),
validity: "1h".into()
}
);
let g: GenerateSpec =
serde_json::from_str(r#"{"format":"self-signed-tls-pair-of","pairOf":"web.key"}"#)
.unwrap();
assert_eq!(
g,
GenerateSpec::SelfSignedTlsPairOf {
pair_of: "web.key".into()
}
);
let g: GenerateSpec = serde_json::from_str(r#"{"format":"age-x25519"}"#).unwrap();
assert_eq!(g, GenerateSpec::AgeX25519);
}
#[test]
fn spiffe_jwt_svid_profile_allows_only_profile_family() {
assert!(KeyAlgorithm::Rsa2048.is_spiffe_jwt_svid_profile());
assert!(KeyAlgorithm::EcdsaP256.is_spiffe_jwt_svid_profile());
assert!(KeyAlgorithm::EcdsaP384.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::EcdsaP521.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::Ed25519.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::Ed25519Nkey.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::Aes256Gcm.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::ChaCha20Poly1305.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::X25519.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::MlKem512.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::MlKem768.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::MlKem1024.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::MlDsa44.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::MlDsa65.is_spiffe_jwt_svid_profile());
assert!(!KeyAlgorithm::MlDsa87.is_spiffe_jwt_svid_profile());
}
#[test]
fn sealing_class_and_kem_algorithms_deserialize() {
assert_eq!(
serde_json::from_str::<Class>("\"sealing\"").unwrap(),
Class::Sealing
);
assert_eq!(
serde_json::from_str::<KeyAlgorithm>("\"x25519\"").unwrap(),
KeyAlgorithm::X25519
);
assert_eq!(
serde_json::from_str::<KeyAlgorithm>("\"ml-kem-768\"").unwrap(),
KeyAlgorithm::MlKem768
);
}
#[test]
fn sealing_class_infers_kv2_engine() {
let entry: KeyEntry = serde_json::from_str(
r#"{
"class": "sealing", "keyType": "x25519", "backend": "bao",
"path": "secret/data/enroll/x25519", "writable": true,
"missing": "error", "description": "enrollment sealing key"
}"#,
)
.unwrap();
assert_eq!(entry.effective_engine(), Engine::Kv2);
}
#[test]
fn key_algorithm_token_round_trips_through_serde() {
for alg in [
KeyAlgorithm::Ed25519,
KeyAlgorithm::Ed25519Nkey,
KeyAlgorithm::Rsa2048,
KeyAlgorithm::EcdsaP256,
KeyAlgorithm::Aes256Gcm,
KeyAlgorithm::ChaCha20Poly1305,
KeyAlgorithm::X25519,
KeyAlgorithm::MlKem512,
KeyAlgorithm::MlKem768,
KeyAlgorithm::MlKem1024,
KeyAlgorithm::MlDsa44,
KeyAlgorithm::MlDsa65,
KeyAlgorithm::MlDsa87,
] {
let json = format!("\"{}\"", alg.token());
let parsed: KeyAlgorithm = serde_json::from_str(&json).expect("token deserializes");
assert_eq!(parsed, alg);
}
}
}