1#![cfg_attr(test, allow(clippy::indexing_slicing))]
11
12use std::ffi::OsString;
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result, bail};
16use base64::Engine as _;
17use basil::{
18 AeadAlgorithm, CiphertextEnvelope, Client, ImportEntry, KeyMaterial, KeyType, NatsJwtType,
19 NatsUserPermissions, SignNatsJwtOptions,
20};
21use basil_core::agent_cli::ExplainArgs;
22use clap::{Subcommand, ValueEnum};
23
24#[derive(Clone, Copy, Debug, ValueEnum)]
25pub enum AeadAlg {
26 Aes256Gcm,
27 Chacha20Poly1305,
28}
29
30impl From<AeadAlg> for AeadAlgorithm {
31 fn from(value: AeadAlg) -> Self {
32 match value {
33 AeadAlg::Aes256Gcm => Self::Aes256Gcm,
34 AeadAlg::Chacha20Poly1305 => Self::Chacha20Poly1305,
35 }
36 }
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, serde::Deserialize)]
48pub enum KeyTypeArg {
49 #[serde(rename = "ed25519")]
50 Ed25519,
51 #[serde(rename = "ed25519-nkey")]
52 Ed25519Nkey,
53 #[serde(rename = "rsa-2048")]
54 #[value(name = "rsa-2048")]
55 Rsa2048,
56 #[serde(rename = "ecdsa-p256")]
57 #[value(name = "ecdsa-p256")]
58 EcdsaP256,
59 #[serde(rename = "ecdsa-p384")]
60 #[value(name = "ecdsa-p384")]
61 EcdsaP384,
62 #[serde(rename = "ecdsa-p521")]
63 #[value(name = "ecdsa-p521")]
64 EcdsaP521,
65 #[serde(rename = "ml-dsa-44")]
66 #[value(name = "ml-dsa-44")]
67 MlDsa44,
68 #[serde(rename = "ml-dsa-65")]
69 #[value(name = "ml-dsa-65")]
70 MlDsa65,
71 #[serde(rename = "ml-dsa-87")]
72 #[value(name = "ml-dsa-87")]
73 MlDsa87,
74 #[serde(rename = "ml-kem-512")]
75 #[value(name = "ml-kem-512")]
76 MlKem512,
77 #[serde(rename = "ml-kem-768")]
78 #[value(name = "ml-kem-768")]
79 MlKem768,
80 #[serde(rename = "ml-kem-1024")]
81 #[value(name = "ml-kem-1024")]
82 MlKem1024,
83}
84
85impl From<KeyTypeArg> for KeyType {
86 fn from(value: KeyTypeArg) -> Self {
87 match value {
88 KeyTypeArg::Ed25519 => Self::Ed25519,
89 KeyTypeArg::Ed25519Nkey => Self::Ed25519Nkey,
90 KeyTypeArg::Rsa2048 => Self::Rsa2048,
91 KeyTypeArg::EcdsaP256 => Self::EcdsaP256,
92 KeyTypeArg::EcdsaP384 => Self::EcdsaP384,
93 KeyTypeArg::EcdsaP521 => Self::EcdsaP521,
94 KeyTypeArg::MlDsa44 => Self::MlDsa44,
95 KeyTypeArg::MlDsa65 => Self::MlDsa65,
96 KeyTypeArg::MlDsa87 => Self::MlDsa87,
97 KeyTypeArg::MlKem512 => Self::MlKem512,
98 KeyTypeArg::MlKem768 => Self::MlKem768,
99 KeyTypeArg::MlKem1024 => Self::MlKem1024,
100 }
101 }
102}
103
104#[derive(Clone, Copy, Debug, ValueEnum)]
105pub enum NatsJwtTypeArg {
106 User,
107 Account,
108 Operator,
109 Signer,
110 Server,
111 Curve,
112}
113
114impl From<NatsJwtTypeArg> for NatsJwtType {
115 fn from(value: NatsJwtTypeArg) -> Self {
116 match value {
117 NatsJwtTypeArg::User => Self::User,
118 NatsJwtTypeArg::Account => Self::Account,
119 NatsJwtTypeArg::Operator => Self::Operator,
120 NatsJwtTypeArg::Signer => Self::Signer,
121 NatsJwtTypeArg::Server => Self::Server,
122 NatsJwtTypeArg::Curve => Self::Curve,
123 }
124 }
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
128pub enum GetOutputFormatArg {
129 Raw,
130 Hex,
131 Base64,
132 #[value(name = "base64-url-no-pad")]
133 Base64UrlNoPad,
134}
135
136#[derive(Clone, Copy, Debug, ValueEnum)]
137pub enum SecretFileModeArg {
138 #[value(name = "0600")]
139 OwnerReadWrite,
140 #[value(name = "0660")]
141 OwnerGroupReadWrite,
142}
143
144impl SecretFileModeArg {
145 const fn mode(self) -> u32 {
146 match self {
147 Self::OwnerReadWrite => 0o600,
148 Self::OwnerGroupReadWrite => 0o660,
149 }
150 }
151}
152
153#[derive(Debug, Subcommand)]
154pub enum Command {
155 NewKey {
157 #[arg(long, default_value = "example.signing_key")]
159 key_id: String,
160 #[arg(long, value_enum, default_value_t = KeyTypeArg::Ed25519)]
162 key_type: KeyTypeArg,
163 },
164
165 Import {
173 #[arg(long)]
175 key_id: String,
176 #[arg(long, value_enum, default_value_t = KeyTypeArg::Ed25519)]
178 key_type: KeyTypeArg,
179 #[arg(long, conflicts_with = "pkcs8_file")]
181 seed_hex: Option<String>,
182 #[arg(long)]
184 pkcs8_file: Option<PathBuf>,
185 #[arg(long)]
187 check: bool,
188 },
189
190 ImportSet {
199 #[arg(long)]
201 file: PathBuf,
202 #[arg(long)]
204 check: bool,
205 },
206
207 Sign {
209 #[arg(long)]
210 key_id: String,
211 payload: String,
212 },
213
214 Verify {
216 #[arg(long)]
217 key_id: String,
218 #[arg(long)]
219 signature: String,
220 payload: String,
221 },
222
223 Encrypt {
225 #[arg(long)]
226 key_id: String,
227 #[arg(long, value_enum, default_value_t = AeadAlg::Aes256Gcm)]
228 algorithm: AeadAlg,
229 #[arg(long)]
230 aad_hex: Option<String>,
231 plaintext: String,
232 },
233
234 Decrypt {
236 #[arg(long)]
237 key_id: String,
238 #[arg(long, value_enum)]
239 algorithm: AeadAlg,
240 #[arg(long)]
241 version: u32,
242 #[arg(long)]
243 nonce: String,
244 #[arg(long)]
245 ciphertext: String,
246 #[arg(long)]
247 aad_hex: Option<String>,
248 },
249
250 Get {
259 #[arg(long)]
260 key_id: String,
261 #[arg(long)]
262 version: Option<u32>,
263 #[arg(long, conflicts_with = "out_file")]
265 raw: bool,
266 #[arg(long, value_enum)]
270 format: Option<GetOutputFormatArg>,
271 #[arg(long)]
273 out_file: Option<PathBuf>,
274 },
275
276 IssueNatsCreds {
280 #[arg(long, conflicts_with = "jwt_file")]
283 jwt: Option<String>,
284 #[arg(long, conflicts_with = "jwt")]
286 jwt_file: Option<PathBuf>,
287 #[arg(long, conflicts_with = "seed_file")]
290 seed: Option<String>,
291 #[arg(long, conflicts_with = "seed")]
293 seed_file: Option<PathBuf>,
294 #[arg(long)]
296 out_file: PathBuf,
297 #[arg(long, value_enum, default_value_t = SecretFileModeArg::OwnerReadWrite)]
299 mode: SecretFileModeArg,
300 },
301
302 Set {
304 #[arg(long)]
305 key_id: String,
306 #[arg(long)]
307 hex: bool,
308 value: String,
309 },
310
311 Rotate {
313 #[arg(long)]
314 key_id: String,
315 },
316
317 List {
319 #[arg(long)]
320 prefix: Option<String>,
321 },
322
323 MintJwt {
325 #[arg(long)]
326 key_id: String,
327 #[arg(long)]
328 sub: String,
329 #[arg(long)]
330 ttl_secs: Option<u64>,
331 #[arg(long, default_value = "{}")]
332 claims_json: String,
333 },
334
335 MintNatsUser {
343 #[arg(long)]
344 key_id: String,
345 #[arg(long)]
346 user_nkey: String,
347 #[arg(long = "issuer-account")]
350 issuer_account: Option<String>,
351 #[arg(long, default_value = "basil-user")]
352 name: String,
353 #[arg(long)]
354 ttl_secs: Option<u64>,
355 #[arg(long = "pub-allow")]
356 pub_allow: Vec<String>,
357 #[arg(long = "pub-deny")]
358 pub_deny: Vec<String>,
359 #[arg(long = "sub-allow")]
360 sub_allow: Vec<String>,
361 #[arg(long = "sub-deny")]
362 sub_deny: Vec<String>,
363 },
364
365 SignNatsJwt {
367 #[arg(long)]
368 key_id: String,
369 #[arg(long, conflicts_with = "claims_file")]
370 claims_json: Option<String>,
371 #[arg(long, conflicts_with = "claims_json")]
372 claims_file: Option<PathBuf>,
373 #[arg(long, value_enum)]
374 expect_type: Option<NatsJwtTypeArg>,
375 #[arg(long, conflicts_with = "expires_at_unix")]
376 ttl_secs: Option<u64>,
377 #[arg(long)]
378 expires_at_unix: Option<u64>,
379 #[arg(long)]
380 issued_at_unix: Option<u64>,
381 #[arg(long)]
382 rewrite_jti: bool,
383 },
384
385 IssueCert {
391 #[arg(long)]
393 key_id: String,
394 #[arg(long)]
396 common_name: String,
397 #[arg(long = "dns-san")]
399 dns_san: Vec<String>,
400 #[arg(long = "ip-san")]
402 ip_san: Vec<String>,
403 #[arg(long)]
405 ttl_secs: u64,
406 },
407
408 Status,
410
411 Health {
416 #[arg(long)]
418 json: bool,
419 },
420
421 Ready {
428 #[arg(long)]
430 json: bool,
431 },
432
433 Reload {
442 #[arg(long)]
445 check: bool,
446 #[arg(long)]
448 json: bool,
449 },
450
451 Revoke {
455 #[arg(long)]
457 trust_domain: String,
458 #[arg(long)]
460 jti: String,
461 #[arg(long)]
463 expires_at_unix: u64,
464 #[arg(long)]
466 json: bool,
467 },
468}
469
470pub async fn run(socket: Option<String>, command: Command) -> Result<()> {
471 if let Command::IssueNatsCreds {
472 jwt,
473 jwt_file,
474 seed,
475 seed_file,
476 out_file,
477 mode,
478 } = command
479 {
480 return issue_nats_creds(
481 jwt,
482 jwt_file.as_deref(),
483 seed,
484 seed_file.as_deref(),
485 &out_file,
486 mode,
487 );
488 }
489
490 match &command {
491 Command::Import {
492 key_type,
493 seed_hex,
494 pkcs8_file,
495 check: true,
496 ..
497 } => return check_import(*key_type, seed_hex.as_deref(), pkcs8_file.as_deref()),
498 Command::ImportSet { file, check: true } => return check_import_set(file),
499 _ => {}
500 }
501
502 let socket = socket.unwrap_or_else(|| basil::constants::DEFAULT_SOCKET_PATH.to_string());
503 let mut client = Client::connect(&socket)
504 .await
505 .with_context(|| format!("connecting to agent at {socket}"))?;
506
507 dispatch(&mut client, command).await?;
508
509 drop(client);
510 Ok(())
511}
512
513#[allow(clippy::too_many_lines)]
514async fn dispatch(client: &mut Client, command: Command) -> Result<()> {
515 match command {
516 Command::NewKey { key_id, key_type } => new_key(client, &key_id, key_type).await,
517 Command::Import {
518 key_id,
519 key_type,
520 seed_hex,
521 pkcs8_file,
522 check,
523 } => {
524 import(
525 client,
526 &key_id,
527 key_type,
528 seed_hex,
529 pkcs8_file.as_deref(),
530 check,
531 )
532 .await
533 }
534 Command::ImportSet { file, check } => import_set(client, &file, check).await,
535 Command::Sign { key_id, payload } => sign(client, &key_id, &payload).await,
536 Command::Verify {
537 key_id,
538 signature,
539 payload,
540 } => verify(client, &key_id, &signature, &payload).await,
541 Command::Encrypt {
542 key_id,
543 algorithm,
544 aad_hex,
545 plaintext,
546 } => encrypt(client, &key_id, algorithm, aad_hex, &plaintext).await,
547 Command::Decrypt {
548 key_id,
549 algorithm,
550 version,
551 nonce,
552 ciphertext,
553 aad_hex,
554 } => {
555 decrypt(
556 client,
557 &key_id,
558 algorithm,
559 version,
560 &nonce,
561 &ciphertext,
562 aad_hex,
563 )
564 .await
565 }
566 Command::Get {
567 key_id,
568 version,
569 raw,
570 format,
571 out_file,
572 } => get(client, &key_id, version, raw, format, out_file.as_deref()).await,
573 Command::IssueNatsCreds {
574 jwt,
575 jwt_file,
576 seed,
577 seed_file,
578 out_file,
579 mode,
580 } => issue_nats_creds(
581 jwt,
582 jwt_file.as_deref(),
583 seed,
584 seed_file.as_deref(),
585 &out_file,
586 mode,
587 ),
588 Command::Set { key_id, hex, value } => set(client, &key_id, hex, value).await,
589 Command::Rotate { key_id } => rotate(client, &key_id).await,
590 Command::List { prefix } => list(client, prefix.as_deref()).await,
591 Command::MintJwt {
592 key_id,
593 sub,
594 ttl_secs,
595 claims_json,
596 } => mint_jwt(client, &key_id, &sub, ttl_secs, &claims_json).await,
597 Command::MintNatsUser {
598 key_id,
599 user_nkey,
600 issuer_account,
601 name,
602 ttl_secs,
603 pub_allow,
604 pub_deny,
605 sub_allow,
606 sub_deny,
607 } => {
608 mint_nats_user(
609 client,
610 &key_id,
611 &user_nkey,
612 issuer_account.as_deref(),
613 &name,
614 ttl_secs,
615 NatsUserPermissions {
616 pub_allow,
617 pub_deny,
618 sub_allow,
619 sub_deny,
620 },
621 )
622 .await
623 }
624 Command::SignNatsJwt {
625 key_id,
626 claims_json,
627 claims_file,
628 expect_type,
629 ttl_secs,
630 expires_at_unix,
631 issued_at_unix,
632 rewrite_jti,
633 } => {
634 sign_nats_jwt(
635 client,
636 &key_id,
637 claims_json.as_deref(),
638 claims_file.as_deref(),
639 expect_type.map(Into::into),
640 ttl_secs,
641 expires_at_unix,
642 issued_at_unix,
643 rewrite_jti,
644 )
645 .await
646 }
647 Command::IssueCert {
648 key_id,
649 common_name,
650 dns_san,
651 ip_san,
652 ttl_secs,
653 } => issue_cert(client, &key_id, &common_name, &dns_san, &ip_san, ttl_secs).await,
654 Command::Status => status(client).await,
655 Command::Health { json } => health(client, json).await,
656 Command::Ready { json } => ready(client, json).await,
657 Command::Reload { check, json } => reload(client, check, json).await,
658 Command::Revoke {
659 trust_domain,
660 jti,
661 expires_at_unix,
662 json,
663 } => revoke(client, &trust_domain, &jti, expires_at_unix, json).await,
664 }
665}
666
667async fn new_key(client: &mut Client, key_id: &str, key_type: KeyTypeArg) -> Result<()> {
668 let key = client.new_key(key_id, key_type.into()).await?;
669 println!("key_id: {}", key.key_id);
670 println!("public_key: {}", hex::encode(key.public_key));
671 Ok(())
672}
673
674#[derive(Debug, serde::Deserialize)]
677struct ManifestEntry {
678 key_id: String,
679 #[serde(default = "default_manifest_key_type")]
680 key_type: KeyTypeArg,
681 #[serde(default)]
682 seed_hex: Option<String>,
683 #[serde(default)]
684 pkcs8_file: Option<PathBuf>,
685}
686
687const fn default_manifest_key_type() -> KeyTypeArg {
688 KeyTypeArg::Ed25519
689}
690
691fn key_material(seed_hex: Option<&str>, pkcs8_file: Option<&Path>) -> Result<KeyMaterial> {
694 match (seed_hex, pkcs8_file) {
695 (Some(seed), None) => {
696 let seed = decode_hex(seed, "seed-hex")?;
697 if seed.len() != 32 {
698 bail!("seed-hex must decode to 32 bytes (got {})", seed.len());
699 }
700 Ok(KeyMaterial::Ed25519Seed(seed))
701 }
702 (None, Some(path)) => {
703 let der = std::fs::read(path)
704 .with_context(|| format!("reading PKCS#8 DER from {}", path.display()))?;
705 if der.is_empty() {
706 bail!("pkcs8-file {} is empty", path.display());
707 }
708 Ok(KeyMaterial::Pkcs8Der(der))
709 }
710 (None, None) => bail!("supply key material: --seed-hex or --pkcs8-file"),
711 (Some(_), Some(_)) => bail!("supply only one of --seed-hex or --pkcs8-file"),
712 }
713}
714
715async fn import(
716 client: &mut Client,
717 key_id: &str,
718 key_type: KeyTypeArg,
719 seed_hex: Option<String>,
720 pkcs8_file: Option<&Path>,
721 check: bool,
722) -> Result<()> {
723 let material = key_material(seed_hex.as_deref(), pkcs8_file)?;
724 if check {
725 return Ok(());
726 }
727 let key = client.import(key_id, key_type.into(), material).await?;
728 println!("key_id: {}", key.key_id);
729 println!("public_key: {}", hex::encode(key.public_key));
730 Ok(())
731}
732
733fn check_import(
734 key_type: KeyTypeArg,
735 seed_hex: Option<&str>,
736 pkcs8_file: Option<&Path>,
737) -> Result<()> {
738 let _material = key_material(seed_hex, pkcs8_file)?;
739 let _key_type = KeyType::from(key_type);
740 Ok(())
741}
742
743fn import_set_entries(file: &Path) -> Result<Vec<ImportEntry>> {
744 let raw = std::fs::read_to_string(file)
745 .with_context(|| format!("reading import manifest {}", file.display()))?;
746 let manifest: Vec<ManifestEntry> =
747 serde_json::from_str(&raw).context("import manifest is not a JSON array of key entries")?;
748 if manifest.is_empty() {
749 bail!("import manifest {} has no entries", file.display());
750 }
751 let mut entries = Vec::with_capacity(manifest.len());
752 for entry in manifest {
753 let material = key_material(entry.seed_hex.as_deref(), entry.pkcs8_file.as_deref())
754 .with_context(|| format!("manifest entry {}", entry.key_id))?;
755 entries.push(ImportEntry {
756 key_id: entry.key_id,
757 key_type: entry.key_type.into(),
758 material,
759 });
760 }
761 Ok(entries)
762}
763
764fn check_import_set(file: &Path) -> Result<()> {
765 let _entries = import_set_entries(file)?;
766 Ok(())
767}
768
769async fn import_set(client: &mut Client, file: &Path, check: bool) -> Result<()> {
770 let entries = import_set_entries(file)?;
771 if check {
772 return Ok(());
773 }
774 for key in client.import_set(entries).await? {
775 println!("key_id: {}", key.key_id);
776 println!("public_key: {}", hex::encode(key.public_key));
777 }
778 Ok(())
779}
780
781async fn sign(client: &mut Client, key_id: &str, payload: &str) -> Result<()> {
782 let sig = client.sign(key_id, payload.as_bytes()).await?;
783 println!("{}", hex::encode(sig));
784 Ok(())
785}
786
787async fn verify(client: &mut Client, key_id: &str, signature: &str, payload: &str) -> Result<()> {
788 let sig = decode_hex(signature, "signature")?;
789 let valid = client.verify(key_id, payload.as_bytes(), &sig).await?;
790 println!("{valid}");
791 if !valid {
792 std::process::exit(1);
793 }
794 Ok(())
795}
796
797async fn encrypt(
798 client: &mut Client,
799 key_id: &str,
800 algorithm: AeadAlg,
801 aad_hex: Option<String>,
802 plaintext: &str,
803) -> Result<()> {
804 let aad = optional_hex(aad_hex, "aad")?;
805 let envelope = client
806 .encrypt(
807 key_id,
808 algorithm.into(),
809 plaintext.as_bytes(),
810 aad.as_deref(),
811 )
812 .await?;
813 println!("algorithm: {}", aead_name(envelope.alg));
814 println!("version: {}", envelope.key_version);
815 println!("nonce: {}", hex::encode(envelope.nonce));
816 println!("ciphertext: {}", hex::encode(envelope.ciphertext));
817 Ok(())
818}
819
820async fn decrypt(
821 client: &mut Client,
822 key_id: &str,
823 algorithm: AeadAlg,
824 version: u32,
825 nonce: &str,
826 ciphertext: &str,
827 aad_hex: Option<String>,
828) -> Result<()> {
829 let aad = optional_hex(aad_hex, "aad")?;
830 let nonce_bytes = if nonce.trim().is_empty() {
835 Vec::new()
836 } else {
837 decode_hex(nonce, "nonce")?
838 };
839 let envelope = CiphertextEnvelope {
840 alg: algorithm.into(),
841 key_version: version,
842 nonce: nonce_bytes,
843 ciphertext: decode_hex(ciphertext, "ciphertext")?,
844 };
845 let plaintext = client
846 .decrypt(key_id, envelope, aad.as_deref())
847 .await
848 .context("decrypt failed")?;
849 println!("{}", hex::encode(plaintext));
850 Ok(())
851}
852
853async fn get(
854 client: &mut Client,
855 key_id: &str,
856 version: Option<u32>,
857 raw: bool,
858 format: Option<GetOutputFormatArg>,
859 out_file: Option<&Path>,
860) -> Result<()> {
861 if raw && format.is_some_and(|format| format != GetOutputFormatArg::Raw) {
862 bail!("--raw cannot be combined with a non-raw --format");
863 }
864 let format = format.or_else(|| raw.then_some(GetOutputFormatArg::Raw));
865 let secret = client.get_secret(key_id, version).await?;
866 let (value, version) = (secret.value, secret.version);
867 if let Some(path) = out_file {
868 let encoded = format.map_or_else(|| value.clone(), |format| encode_secret(&value, format));
869 return write_secret_file(path, &encoded)
870 .with_context(|| format!("writing secret to {}", path.display()));
871 }
872 if let Some(format) = format {
873 use std::io::Write as _;
874
875 let encoded = encode_secret(&value, format);
876 return std::io::stdout()
877 .write_all(&encoded)
878 .and_then(|()| {
879 if format == GetOutputFormatArg::Raw {
880 Ok(())
881 } else {
882 std::io::stdout().write_all(b"\n")
883 }
884 })
885 .context("writing formatted secret to stdout");
886 }
887 println!("version: {version}");
888 println!("value: {}", hex::encode(value));
889 Ok(())
890}
891
892fn encode_secret(value: &[u8], format: GetOutputFormatArg) -> Vec<u8> {
893 match format {
894 GetOutputFormatArg::Raw => value.to_vec(),
895 GetOutputFormatArg::Hex => hex::encode(value).into_bytes(),
896 GetOutputFormatArg::Base64 => base64::engine::general_purpose::STANDARD
897 .encode(value)
898 .into_bytes(),
899 GetOutputFormatArg::Base64UrlNoPad => base64::engine::general_purpose::URL_SAFE_NO_PAD
900 .encode(value)
901 .into_bytes(),
902 }
903}
904
905fn write_secret_file(path: &Path, value: &[u8]) -> Result<()> {
908 write_secret_file_with_mode(path, value, 0o600)
909}
910
911fn write_secret_file_with_mode(path: &Path, value: &[u8], mode: u32) -> Result<()> {
914 use std::io::Write as _;
915 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
916
917 let parent = path
918 .parent()
919 .filter(|parent| !parent.as_os_str().is_empty())
920 .unwrap_or_else(|| Path::new("."));
921 let file_name = path
922 .file_name()
923 .context("secret output path must name a file")?;
924 let mut temporary_name = OsString::from(".");
925 temporary_name.push(file_name);
926 temporary_name.push(format!(".{}.tmp", std::process::id()));
927 let temporary_path = parent.join(temporary_name);
928
929 let mut file = std::fs::OpenOptions::new()
930 .write(true)
931 .create_new(true)
932 .mode(mode)
933 .open(&temporary_path)?;
934 let result = (|| -> Result<()> {
935 file.set_permissions(std::fs::Permissions::from_mode(mode))?;
936 file.write_all(value)?;
937 file.sync_all()?;
938 drop(file);
939 std::fs::rename(&temporary_path, path)?;
940 std::fs::File::open(parent)?.sync_all()?;
941 Ok(())
942 })();
943 if result.is_err() {
944 let _ = std::fs::remove_file(&temporary_path);
945 }
946 result
947}
948
949fn issue_nats_creds(
950 jwt: Option<String>,
951 jwt_file: Option<&Path>,
952 seed: Option<String>,
953 seed_file: Option<&Path>,
954 out_file: &Path,
955 mode: SecretFileModeArg,
956) -> Result<()> {
957 let jwt = read_exactly_one_secret_arg(jwt, jwt_file, "jwt")?;
958 let seed = read_exactly_one_secret_arg(seed, seed_file, "seed")?;
959 let creds =
960 basil_nats::format_user_creds(&jwt, &seed).context("formatting NATS credentials")?;
961 write_secret_file_with_mode(out_file, creds.as_bytes(), mode.mode())
962 .with_context(|| format!("writing NATS credentials to {}", out_file.display()))?;
963 Ok(())
964}
965
966fn read_exactly_one_secret_arg(
967 inline: Option<String>,
968 file: Option<&Path>,
969 field: &'static str,
970) -> Result<String> {
971 match (inline, file) {
972 (Some(value), None) => Ok(value),
973 (None, Some(path)) => std::fs::read_to_string(path)
974 .with_context(|| format!("reading {field} file {}", path.display())),
975 (None, None) => bail!("supply --{field} or --{field}-file"),
976 (Some(_), Some(_)) => bail!("supply only one of --{field} or --{field}-file"),
977 }
978}
979
980async fn set(client: &mut Client, key_id: &str, hex: bool, value: String) -> Result<()> {
981 let value = if hex {
982 decode_hex(&value, "value")?
983 } else {
984 value.into_bytes()
985 };
986 let version = client.set_secret(key_id, &value).await?;
987 println!("version: {version}");
988 Ok(())
989}
990
991async fn rotate(client: &mut Client, key_id: &str) -> Result<()> {
992 let version = client.rotate_secret(key_id).await?;
993 println!("version: {version}");
994 Ok(())
995}
996
997async fn list(client: &mut Client, prefix: Option<&str>) -> Result<()> {
998 for key in client.list_catalog(prefix).await? {
999 println!(
1000 "{}\t{}\t{}\t{}",
1001 key.name,
1002 key.kind,
1003 key.key_type.unwrap_or_default(),
1004 key.latest_version
1005 );
1006 }
1007 Ok(())
1008}
1009
1010async fn mint_jwt(
1011 client: &mut Client,
1012 key_id: &str,
1013 sub: &str,
1014 ttl_secs: Option<u64>,
1015 claims_json: &str,
1016) -> Result<()> {
1017 let claims: serde_json::Value =
1018 serde_json::from_str(claims_json).context("claims-json is not valid JSON")?;
1019 let jwt = client.mint_jwt(key_id, sub, ttl_secs, claims).await?;
1020 println!("{}", jwt.token);
1021 if let Some(expires_at) = jwt.expires_at {
1022 println!("expires_at: {expires_at}");
1023 }
1024 Ok(())
1025}
1026
1027async fn mint_nats_user(
1028 client: &mut Client,
1029 key_id: &str,
1030 user_nkey: &str,
1031 issuer_account: Option<&str>,
1032 name: &str,
1033 ttl_secs: Option<u64>,
1034 permissions: NatsUserPermissions,
1035) -> Result<()> {
1036 let token = client
1037 .mint_nats_user(
1038 key_id,
1039 user_nkey,
1040 issuer_account,
1041 name,
1042 ttl_secs,
1043 permissions,
1044 )
1045 .await?;
1046 println!("{token}");
1047 Ok(())
1048}
1049
1050#[allow(clippy::too_many_arguments)]
1051async fn sign_nats_jwt(
1052 client: &mut Client,
1053 key_id: &str,
1054 claims_json: Option<&str>,
1055 claims_file: Option<&Path>,
1056 expected_type: Option<NatsJwtType>,
1057 ttl_secs: Option<u64>,
1058 expires_at: Option<u64>,
1059 issued_at: Option<u64>,
1060 rewrite_jti: bool,
1061) -> Result<()> {
1062 let claims_text = match (claims_json, claims_file) {
1063 (Some(value), None) => value.to_string(),
1064 (None, Some(path)) => std::fs::read_to_string(path)
1065 .with_context(|| format!("reading claims file `{}`", path.display()))?,
1066 (None, None) | (Some(_), Some(_)) => {
1067 bail!("supply exactly one of --claims-json or --claims-file")
1068 }
1069 };
1070 let _: serde_json::Value =
1071 serde_json::from_str(&claims_text).context("NATS JWT claims are not valid JSON")?;
1072 let jwt = client
1073 .sign_nats_jwt_json(
1074 key_id,
1075 claims_text.into_bytes(),
1076 SignNatsJwtOptions {
1077 expected_type,
1078 ttl_secs,
1079 expires_at,
1080 issued_at,
1081 rewrite_jti,
1082 },
1083 )
1084 .await?;
1085 println!("{}", jwt.token);
1086 if let Some(expires_at) = jwt.expires_at {
1087 println!("expires_at: {expires_at}");
1088 }
1089 Ok(())
1090}
1091
1092async fn issue_cert(
1093 client: &mut Client,
1094 key_id: &str,
1095 common_name: &str,
1096 dns_sans: &[String],
1097 ip_sans: &[String],
1098 ttl_secs: u64,
1099) -> Result<()> {
1100 let issued = client
1101 .issue_certificate(key_id, common_name, dns_sans, ip_sans, ttl_secs)
1102 .await?;
1103 print!("{}", pem_blocks("CERTIFICATE", &issued.cert_chain_der));
1106 print!("{}", pem_blocks("CERTIFICATE", &issued.ca_chain_der));
1111 print!("{}", pem_block("PRIVATE KEY", &issued.private_key_der));
1113 Ok(())
1114}
1115
1116fn pem_blocks(label: &str, ders: &[Vec<u8>]) -> String {
1118 ders.iter().map(|der| pem_block(label, der)).collect()
1119}
1120
1121fn pem_block(label: &str, der: &[u8]) -> String {
1124 use base64::Engine as _;
1125 use std::fmt::Write as _;
1126 let body = base64::engine::general_purpose::STANDARD.encode(der);
1127 let mut out = String::new();
1128 let _ = writeln!(out, "-----BEGIN {label}-----");
1130 for chunk in body.as_bytes().chunks(64) {
1131 out.push_str(std::str::from_utf8(chunk).unwrap_or_default());
1133 out.push('\n');
1134 }
1135 let _ = writeln!(out, "-----END {label}-----");
1136 out
1137}
1138
1139async fn status(client: &mut Client) -> Result<()> {
1140 let status = client.status().await?;
1141 println!("backend: {}", status.backend);
1142 println!("version: {}", status.version);
1143 println!("protocol: {}", status.protocol);
1144 Ok(())
1145}
1146
1147async fn health(client: &mut Client, json: bool) -> Result<()> {
1152 let health = client.health().await?;
1153 if json {
1154 println!("{}", health_json(&health));
1156 } else {
1157 println!("alive: {}", health.alive);
1158 println!("version: {}", health.version);
1159 }
1160 Ok(())
1161}
1162
1163fn health_json(health: &basil::AgentHealth) -> serde_json::Value {
1167 serde_json::json!({ "alive": health.alive, "version": health.version })
1168}
1169
1170async fn ready(client: &mut Client, json: bool) -> Result<()> {
1175 let r = client.readiness().await?;
1176 if json {
1177 println!("{}", ready_json(&r));
1178 } else {
1179 println!("ready: {}", r.ready);
1180 println!("reason: {}", r.reason);
1181 println!("generation: {}", r.generation);
1182 println!("keys_total: {}", r.keys_total);
1183 println!("keys_present: {}", r.keys_present);
1184 println!("keys_required_missing: {}", r.keys_required_missing);
1185 println!("keys_optional_missing: {}", r.keys_optional_missing);
1186 }
1187 if ready_exit_code(&r) != 0 {
1188 std::process::exit(1);
1191 }
1192 Ok(())
1193}
1194
1195fn ready_json(r: &basil::AgentReadiness) -> serde_json::Value {
1199 serde_json::json!({
1200 "ready": r.ready,
1201 "reason": r.reason.to_string(),
1202 "generation": r.generation,
1203 "keys_total": r.keys_total,
1204 "keys_present": r.keys_present,
1205 "keys_required_missing": r.keys_required_missing,
1206 "keys_optional_missing": r.keys_optional_missing,
1207 })
1208}
1209
1210const fn ready_exit_code(r: &basil::AgentReadiness) -> i32 {
1215 if r.ready { 0 } else { 1 }
1216}
1217
1218async fn reload(client: &mut Client, check: bool, json: bool) -> Result<()> {
1228 let r = client.reload(check).await?;
1229 if json {
1230 println!("{}", reload_json(&r));
1231 } else if let Some(rej) = &r.rejection {
1232 println!("reload rejected: {} ({})", rej.reason, rej.message);
1233 println!(
1234 "previous_generation: {} (still serving)",
1235 r.previous_generation
1236 );
1237 } else if check {
1238 println!("checked: ok (no swap)");
1239 println!("previous_generation: {}", r.previous_generation);
1240 println!("would_be_generation: {}", r.new_generation);
1241 println!("key_count: {}", r.key_count);
1242 println!("grant_count: {}", r.grant_count);
1243 } else {
1244 println!("applied: {}", r.applied);
1245 println!("previous_generation: {}", r.previous_generation);
1246 println!("new_generation: {}", r.new_generation);
1247 println!("key_count: {}", r.key_count);
1248 println!("grant_count: {}", r.grant_count);
1249 }
1250 if reload_exit_code(&r) != 0 {
1251 std::process::exit(1);
1255 }
1256 Ok(())
1257}
1258
1259fn reload_json(r: &basil::AgentReload) -> serde_json::Value {
1264 serde_json::json!({
1265 "applied": r.applied,
1266 "checked": r.checked,
1267 "previous_generation": r.previous_generation,
1268 "new_generation": r.new_generation,
1269 "key_count": r.key_count,
1270 "grant_count": r.grant_count,
1271 "rejection": r.rejection.as_ref().map(|rej| serde_json::json!({
1272 "reason": rej.reason,
1273 "message": rej.message,
1274 })),
1275 })
1276}
1277
1278const fn reload_exit_code(r: &basil::AgentReload) -> i32 {
1285 if r.succeeded() { 0 } else { 1 }
1286}
1287
1288pub async fn explain_live(socket: Option<&str>, args: &ExplainArgs) -> Result<()> {
1297 let (Some(op), Some(key)) = (args.op_token(), args.key()) else {
1298 bail!("--op and --key are required for `explain --live`");
1299 };
1300 let socket = socket.map_or_else(
1301 || basil::constants::DEFAULT_SOCKET_PATH.to_string(),
1302 str::to_owned,
1303 );
1304 let mut client = Client::connect(&socket)
1305 .await
1306 .with_context(|| format!("connecting to agent at {socket}"))?;
1307 let result = explain(&mut client, args.subject(), op, key, args.json()).await;
1308 drop(client);
1309 result
1310}
1311
1312async fn explain(
1314 client: &mut Client,
1315 subject: &str,
1316 op: &str,
1317 key: &str,
1318 json: bool,
1319) -> Result<()> {
1320 let explanation = client.explain(subject, op, key).await?;
1321 if json {
1322 println!("{}", explain_json(&explanation));
1323 return Ok(());
1324 }
1325
1326 println!(
1327 "{} {} {} for subject {}",
1328 explanation.decision.to_uppercase(),
1329 explanation.op,
1330 explanation.key,
1331 explanation.subject
1332 );
1333 if explanation.decision == "allow" {
1334 println!("via: {}", explanation.via);
1335 if let Some(rule) = &explanation.matched_rule {
1336 println!("matched_rule: {}", rule.rule);
1337 println!("action: {}", rule.action);
1338 println!("target: {}", rule.target);
1339 }
1340 } else {
1341 println!("reason: {}", explanation.reason);
1342 }
1343 Ok(())
1344}
1345
1346fn explain_json(explanation: &basil::AgentExplanation) -> serde_json::Value {
1349 let mut obj = serde_json::Map::new();
1350 obj.insert("subject".into(), explanation.subject.clone().into());
1351 obj.insert("op".into(), explanation.op.clone().into());
1352 obj.insert("key".into(), explanation.key.clone().into());
1353 obj.insert("decision".into(), explanation.decision.clone().into());
1354 if explanation.decision == "allow" {
1355 obj.insert("via".into(), explanation.via.clone().into());
1356 let matched = explanation
1357 .matched_rule
1358 .as_ref()
1359 .map_or(serde_json::Value::Null, |m| {
1360 serde_json::json!({
1361 "rule": m.rule,
1362 "via": m.via,
1363 "action": m.action,
1364 "target": m.target,
1365 })
1366 });
1367 obj.insert("matched_rule".into(), matched);
1368 } else {
1369 obj.insert("reason".into(), explanation.reason.clone().into());
1370 }
1371 serde_json::Value::Object(obj)
1372}
1373
1374async fn revoke(
1376 client: &mut Client,
1377 trust_domain: &str,
1378 jti: &str,
1379 expires_at_unix: u64,
1380 json: bool,
1381) -> Result<()> {
1382 let revocation = client.revoke(trust_domain, jti, expires_at_unix).await?;
1383 if json {
1384 println!("{}", revoke_json(&revocation));
1385 return Ok(());
1386 }
1387 println!("revoked: {}", revocation.jti);
1388 println!("trust_domain: {}", revocation.trust_domain);
1389 println!("expires_at_unix: {}", revocation.expires_at_unix);
1390 println!("persisted: {}", revocation.persisted);
1391 Ok(())
1392}
1393
1394fn revoke_json(revocation: &basil::AgentRevocation) -> serde_json::Value {
1396 serde_json::json!({
1397 "trust_domain": revocation.trust_domain,
1398 "jti": revocation.jti,
1399 "expires_at_unix": revocation.expires_at_unix,
1400 "persisted": revocation.persisted,
1401 })
1402}
1403
1404fn optional_hex(value: Option<String>, field: &'static str) -> Result<Option<Vec<u8>>> {
1405 value.map(|value| decode_hex(&value, field)).transpose()
1406}
1407
1408fn decode_hex(value: &str, field: &'static str) -> Result<Vec<u8>> {
1409 let trimmed = value.trim();
1410 if trimmed.is_empty() {
1411 bail!("{field} hex must not be empty");
1412 }
1413 hex::decode(trimmed).with_context(|| format!("{field} is not valid hex"))
1414}
1415
1416const fn aead_name(value: AeadAlgorithm) -> &'static str {
1417 match value {
1418 AeadAlgorithm::Aes256Gcm => "aes256-gcm",
1419 AeadAlgorithm::Chacha20Poly1305 => "chacha20-poly1305",
1420 }
1421}
1422
1423#[cfg(test)]
1424mod tests {
1425 use super::{
1426 Command as ClientCommand, KeyMaterial, ManifestEntry, check_import_set, explain_json,
1427 health_json, key_material, ready_exit_code, ready_json, reload_exit_code, reload_json,
1428 revoke_json,
1429 };
1430 use crate::Cli;
1431 use basil::{
1432 AgentExplanation, AgentHealth, AgentReadiness, AgentReload, AgentRevocation, MatchedRule,
1433 ReadinessReason, ReloadRejection,
1434 };
1435 use clap::Parser;
1436 use std::path::Path;
1437
1438 #[test]
1439 #[allow(clippy::too_many_lines)]
1440 fn parses_grpc_command_surface() {
1441 Cli::parse_from(["basil", "status"]);
1442 Cli::parse_from(["basil", "health"]);
1443 Cli::parse_from(["basil", "health", "--json"]);
1444 Cli::parse_from(["basil", "ready"]);
1445 Cli::parse_from(["basil", "ready", "--json"]);
1446 Cli::parse_from(["basil", "reload"]);
1447 Cli::parse_from(["basil", "reload", "--check"]);
1448 Cli::parse_from(["basil", "reload", "--check", "--json"]);
1449 Cli::parse_from([
1450 "basil",
1451 "new-key",
1452 "--key-id",
1453 "asym.rsa",
1454 "--key-type",
1455 "rsa-2048",
1456 ]);
1457 Cli::parse_from([
1458 "basil",
1459 "revoke",
1460 "--trust-domain",
1461 "example.test",
1462 "--jti",
1463 "token-1",
1464 "--expires-at-unix",
1465 "2000000000",
1466 "--json",
1467 ]);
1468 Cli::parse_from([
1469 "basil",
1470 "explain",
1471 "--subject",
1472 "svc.orders",
1473 "--op",
1474 "sign",
1475 "--key",
1476 "app.signing",
1477 "--json",
1478 ]);
1479 Cli::parse_from([
1480 "basil",
1481 "import",
1482 "--key-id",
1483 "byok.signer",
1484 "--seed-hex",
1485 &"00".repeat(32),
1486 "--check",
1487 ]);
1488 Cli::parse_from([
1489 "basil",
1490 "import",
1491 "--key-id",
1492 "byok.signer",
1493 "--key-type",
1494 "ed25519",
1495 "--pkcs8-file",
1496 "/tmp/key.der",
1497 ]);
1498 Cli::parse_from([
1499 "basil",
1500 "import-set",
1501 "--file",
1502 "/tmp/manifest.json",
1503 "--check",
1504 ]);
1505 Cli::parse_from(["basil", "get", "--key-id", "app.secret"]);
1506 Cli::parse_from(["basil", "get", "--key-id", "app.secret", "--raw"]);
1507 Cli::parse_from([
1508 "basil",
1509 "get",
1510 "--key-id",
1511 "app.secret",
1512 "--format",
1513 "base64",
1514 ]);
1515 Cli::parse_from([
1516 "basil",
1517 "get",
1518 "--key-id",
1519 "app.secret",
1520 "--format",
1521 "base64-url-no-pad",
1522 ]);
1523 Cli::parse_from([
1524 "basil",
1525 "get",
1526 "--key-id",
1527 "app.secret",
1528 "--format",
1529 "hex",
1530 "--out-file",
1531 "/run/secrets/app.hex",
1532 ]);
1533 Cli::parse_from([
1534 "basil",
1535 "get",
1536 "--key-id",
1537 "app.secret",
1538 "--out-file",
1539 "/run/secrets/app",
1540 ]);
1541 Cli::parse_from([
1542 "basil",
1543 "issue-nats-creds",
1544 "--jwt-file",
1545 "/run/secrets/user.jwt",
1546 "--seed-file",
1547 "/run/secrets/user.seed",
1548 "--out-file",
1549 "/run/secrets/user.creds",
1550 "--mode",
1551 "0660",
1552 ]);
1553 Cli::parse_from(["basil", "set", "--key-id", "app.secret", "hunter2"]);
1554 Cli::parse_from(["basil", "rotate", "--key-id", "app.secret"]);
1555 Cli::parse_from(["basil", "list", "--prefix", "app."]);
1556 Cli::parse_from([
1557 "basil",
1558 "mint-jwt",
1559 "--key-id",
1560 "jwt.issuer",
1561 "--sub",
1562 "subject",
1563 ]);
1564 Cli::parse_from([
1565 "basil",
1566 "mint-nats-user",
1567 "--key-id",
1568 "nats.account",
1569 "--user-nkey",
1570 "UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1571 "--pub-allow",
1572 "orders.>",
1573 "--sub-deny",
1574 "_INBOX.>",
1575 ]);
1576 Cli::parse_from([
1577 "basil",
1578 "sign-nats-jwt",
1579 "--key-id",
1580 "nats.account",
1581 "--claims-json",
1582 r#"{"sub":"UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","name":"svc","nats":{"type":"user","version":2}}"#,
1583 "--expect-type",
1584 "user",
1585 "--ttl-secs",
1586 "3600",
1587 "--rewrite-jti",
1588 ]);
1589 Cli::parse_from([
1590 "basil",
1591 "issue-cert",
1592 "--key-id",
1593 "spire.x509",
1594 "--common-name",
1595 "svc.example.org",
1596 "--dns-san",
1597 "svc.example.org",
1598 "--ip-san",
1599 "127.0.0.1",
1600 "--ttl-secs",
1601 "3600",
1602 ]);
1603 Cli::parse_from([
1604 "basil",
1605 "encrypt",
1606 "--key-id",
1607 "app.aead",
1608 "--algorithm",
1609 "aes256-gcm",
1610 "secret",
1611 ]);
1612 Cli::parse_from([
1613 "basil",
1614 "decrypt",
1615 "--key-id",
1616 "app.aead",
1617 "--algorithm",
1618 "aes256-gcm",
1619 "--version",
1620 "1",
1621 "--nonce",
1622 "000000000000000000000000",
1623 "--ciphertext",
1624 "00",
1625 ]);
1626 }
1627
1628 #[test]
1629 fn explain_is_top_level_and_parses_subject_op_key_and_json() {
1630 let cli = Cli::parse_from([
1631 "basil",
1632 "explain",
1633 "--subject",
1634 "svc.orders",
1635 "--op",
1636 "sign",
1637 "--key",
1638 "app.signing",
1639 "--json",
1640 ]);
1641 match cli.command {
1642 crate::Command::Explain(args) => {
1643 assert_eq!(args.subject(), "svc.orders");
1644 assert_eq!(args.op_token(), Some("sign"));
1645 assert_eq!(args.key(), Some("app.signing"));
1646 assert!(args.json(), "--json flag parsed");
1647 assert!(!args.is_live(), "offline is the default path");
1648 }
1649 other => panic!("unexpected command: {other:?}"),
1650 }
1651
1652 let cli = Cli::parse_from([
1654 "basil",
1655 "explain",
1656 "--subject",
1657 "s",
1658 "--op",
1659 "get",
1660 "--key",
1661 "k",
1662 ]);
1663 match cli.command {
1664 crate::Command::Explain(args) => {
1665 assert!(!args.json(), "--json defaults off");
1666 }
1667 other => panic!("unexpected command: {other:?}"),
1668 }
1669 }
1670
1671 #[test]
1672 fn explain_live_flag_selects_the_broker_path() {
1673 let cli = Cli::parse_from([
1674 "basil", "explain", "--subject", "s", "--op", "get", "--key", "k", "--live",
1675 ]);
1676 match cli.command {
1677 crate::Command::Explain(args) => {
1678 assert!(args.is_live(), "--live selects the running-broker path");
1679 }
1680 other => panic!("unexpected command: {other:?}"),
1681 }
1682 }
1683
1684 #[test]
1685 fn explain_effective_needs_no_op_or_key_offline() {
1686 let cli = Cli::parse_from(["basil", "explain", "--subject", "s", "--effective"]);
1687 match cli.command {
1688 crate::Command::Explain(args) => {
1689 assert!(!args.is_live());
1690 assert_eq!(args.op_token(), None, "--effective makes --op optional");
1691 assert_eq!(args.key(), None, "--effective makes --key optional");
1692 }
1693 other => panic!("unexpected command: {other:?}"),
1694 }
1695 }
1696
1697 #[test]
1698 fn explain_live_conflicts_with_effective() {
1699 let err = Cli::try_parse_from(["basil", "explain", "--subject", "s", "--live", "--effective"])
1700 .expect_err("--live has no effective mode; clap must reject the combination");
1701 assert!(
1702 err.to_string().contains("cannot be used with"),
1703 "expected a clap conflict error, got: {err}"
1704 );
1705 }
1706
1707 #[test]
1708 fn config_namespace_is_gone() {
1709 let err = Cli::try_parse_from(["basil", "config", "explain", "--subject", "s"])
1712 .expect_err("config namespace must be gone");
1713 assert!(
1714 err.to_string().contains("unrecognized subcommand")
1715 || err.to_string().contains("invalid subcommand"),
1716 "{err}"
1717 );
1718 assert!(
1719 Cli::try_parse_from(["basil", "config"]).is_err(),
1720 "bare `basil config` must not parse either"
1721 );
1722 }
1723
1724 #[test]
1725 fn explain_json_allow_carries_matched_rule_provenance() {
1726 let explanation = AgentExplanation {
1727 subject: "svc.orders".into(),
1728 op: "sign".into(),
1729 key: "app.signing".into(),
1730 decision: "allow".into(),
1731 via: "user".into(),
1732 reason: String::new(),
1733 matched_rule: Some(MatchedRule {
1734 rule: "rule-7".into(),
1735 via: "user".into(),
1736 action: "sign".into(),
1737 target: "app.*".into(),
1738 subject: "svc.orders".into(),
1739 }),
1740 };
1741 let json = explain_json(&explanation);
1742 assert_eq!(json["decision"], "allow");
1743 assert_eq!(json["subject"], "svc.orders");
1744 assert_eq!(json["op"], "sign");
1745 assert_eq!(json["key"], "app.signing");
1746 assert_eq!(json["via"], "user");
1747 assert_eq!(json["matched_rule"]["rule"], "rule-7");
1748 assert_eq!(json["matched_rule"]["via"], "user");
1749 assert_eq!(json["matched_rule"]["action"], "sign");
1750 assert_eq!(json["matched_rule"]["target"], "app.*");
1751 assert!(json.get("reason").is_none(), "allow omits reason");
1753 }
1754
1755 #[test]
1756 fn explain_json_deny_carries_reason_not_rule() {
1757 let explanation = AgentExplanation {
1758 subject: "svc.orders".into(),
1759 op: "sign".into(),
1760 key: "app.signing".into(),
1761 decision: "deny".into(),
1762 via: String::new(),
1763 reason: "no_matching_rule".into(),
1764 matched_rule: None,
1765 };
1766 let json = explain_json(&explanation);
1767 assert_eq!(json["decision"], "deny");
1768 assert_eq!(json["subject"], "svc.orders");
1769 assert_eq!(json["reason"], "no_matching_rule");
1770 assert!(json.get("via").is_none(), "deny omits via");
1772 assert!(
1773 json.get("matched_rule").is_none(),
1774 "deny omits matched_rule"
1775 );
1776 }
1777
1778 #[test]
1779 fn new_key_parses_default_and_explicit_key_type() {
1780 let cli = Cli::parse_from(["basil", "new-key", "--key-id", "asym.default"]);
1781 match cli.command {
1782 crate::Command::Client(ClientCommand::NewKey { key_id, key_type }) => {
1783 assert_eq!(key_id, "asym.default");
1784 assert_eq!(key_type, super::KeyTypeArg::Ed25519);
1785 }
1786 other => panic!("unexpected command: {other:?}"),
1787 }
1788
1789 let cli = Cli::parse_from([
1790 "basil",
1791 "new-key",
1792 "--key-id",
1793 "asym.nkey",
1794 "--key-type",
1795 "ed25519-nkey",
1796 ]);
1797 match cli.command {
1798 crate::Command::Client(ClientCommand::NewKey { key_id, key_type }) => {
1799 assert_eq!(key_id, "asym.nkey");
1800 assert_eq!(key_type, super::KeyTypeArg::Ed25519Nkey);
1801 }
1802 other => panic!("unexpected command: {other:?}"),
1803 }
1804 }
1805
1806 #[test]
1807 fn import_check_flags_parse() {
1808 let cli = Cli::parse_from([
1809 "basil",
1810 "import",
1811 "--key-id",
1812 "byok.signer",
1813 "--seed-hex",
1814 &"00".repeat(32),
1815 "--check",
1816 ]);
1817 match cli.command {
1818 crate::Command::Client(ClientCommand::Import { check, .. }) => {
1819 assert!(check, "--check flag parsed for import");
1820 }
1821 other => panic!("unexpected command: {other:?}"),
1822 }
1823
1824 let cli = Cli::parse_from([
1825 "basil",
1826 "import-set",
1827 "--file",
1828 "/tmp/manifest.json",
1829 "--check",
1830 ]);
1831 match cli.command {
1832 crate::Command::Client(ClientCommand::ImportSet { check, .. }) => {
1833 assert!(check, "--check flag parsed for import-set");
1834 }
1835 other => panic!("unexpected command: {other:?}"),
1836 }
1837 }
1838
1839 #[test]
1840 fn get_format_parses_as_materialization_override() {
1841 let cli = Cli::parse_from([
1842 "basil",
1843 "get",
1844 "--key-id",
1845 "netbird.datastore",
1846 "--format",
1847 "base64",
1848 "--out-file",
1849 "/run/secrets/netbird-datastore-key",
1850 ]);
1851 match cli.command {
1852 crate::Command::Client(ClientCommand::Get {
1853 key_id,
1854 format,
1855 out_file,
1856 ..
1857 }) => {
1858 assert_eq!(key_id, "netbird.datastore");
1859 assert_eq!(format, Some(super::GetOutputFormatArg::Base64));
1860 assert_eq!(
1861 out_file.as_deref(),
1862 Some(Path::new("/run/secrets/netbird-datastore-key"))
1863 );
1864 }
1865 other => panic!("unexpected command: {other:?}"),
1866 }
1867 }
1868
1869 #[test]
1870 fn issue_nats_creds_parses_local_file_inputs() {
1871 let cli = Cli::parse_from([
1872 "basil",
1873 "issue-nats-creds",
1874 "--jwt-file",
1875 "/run/secrets/user.jwt",
1876 "--seed-file",
1877 "/run/secrets/user.seed",
1878 "--out-file",
1879 "/run/secrets/user.creds",
1880 "--mode",
1881 "0660",
1882 ]);
1883 match cli.command {
1884 crate::Command::Client(ClientCommand::IssueNatsCreds {
1885 jwt_file,
1886 seed_file,
1887 out_file,
1888 mode,
1889 ..
1890 }) => {
1891 assert_eq!(
1892 jwt_file.as_deref(),
1893 Some(Path::new("/run/secrets/user.jwt"))
1894 );
1895 assert_eq!(
1896 seed_file.as_deref(),
1897 Some(Path::new("/run/secrets/user.seed"))
1898 );
1899 assert_eq!(out_file, Path::new("/run/secrets/user.creds"));
1900 assert_eq!(mode.mode(), 0o660);
1901 }
1902 other => panic!("unexpected command: {other:?}"),
1903 }
1904 }
1905
1906 #[test]
1907 fn secret_encoding_formats_match_external_consumers() {
1908 let value = [251, 255, 0, 1];
1909 assert_eq!(
1910 super::encode_secret(&value, super::GetOutputFormatArg::Raw),
1911 value
1912 );
1913 assert_eq!(
1914 super::encode_secret(&value, super::GetOutputFormatArg::Hex),
1915 b"fbff0001"
1916 );
1917 assert_eq!(
1918 super::encode_secret(&value, super::GetOutputFormatArg::Base64),
1919 b"+/8AAQ=="
1920 );
1921 assert_eq!(
1922 super::encode_secret(&value, super::GetOutputFormatArg::Base64UrlNoPad),
1923 b"-_8AAQ"
1924 );
1925 }
1926
1927 #[test]
1928 fn pem_block_frames_der_at_64_columns() {
1929 let der = vec![0xABu8; 48];
1931 let pem = super::pem_block("CERTIFICATE", &der);
1932 assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"));
1933 assert!(pem.ends_with("-----END CERTIFICATE-----\n"));
1934 let body: Vec<&str> = pem.lines().filter(|l| !l.starts_with("-----")).collect();
1935 assert_eq!(body.len(), 1);
1936 assert_eq!(body.first().map(|l| l.len()), Some(64));
1937 }
1938
1939 #[test]
1940 fn pem_blocks_emits_one_block_per_der() {
1941 let ders = vec![vec![1u8; 10], vec![2u8; 10]];
1942 let pem = super::pem_blocks("CERTIFICATE", &ders);
1943 assert_eq!(pem.matches("-----BEGIN CERTIFICATE-----").count(), 2);
1944 assert_eq!(pem.matches("-----END CERTIFICATE-----").count(), 2);
1945 }
1946
1947 #[test]
1948 fn seed_hex_decodes_to_a_32_byte_ed25519_seed() {
1949 let seed = "01".repeat(32);
1950 let material = key_material(Some(&seed), None).expect("32-byte seed must decode");
1951 assert_eq!(material, KeyMaterial::Ed25519Seed(vec![1u8; 32]));
1952 }
1953
1954 #[test]
1955 fn seed_hex_of_wrong_length_is_rejected() {
1956 let short = "01".repeat(31);
1958 assert!(key_material(Some(&short), None).is_err());
1959 }
1960
1961 #[test]
1962 fn material_requires_exactly_one_source() {
1963 assert!(key_material(None, None).is_err());
1965 let seed = "01".repeat(32);
1967 assert!(key_material(Some(&seed), Some(Path::new("/tmp/key.der"))).is_err());
1968 }
1969
1970 #[test]
1971 fn manifest_entry_parses_seed_hex_and_defaults_key_type() {
1972 let json = r#"{"key_id": "byok.a", "seed_hex": "00ff00ff"}"#;
1973 let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1974 assert_eq!(entry.key_id, "byok.a");
1975 assert_eq!(entry.key_type, super::KeyTypeArg::Ed25519);
1976 assert_eq!(entry.seed_hex.as_deref(), Some("00ff00ff"));
1977 assert!(entry.pkcs8_file.is_none());
1978 }
1979
1980 #[test]
1981 fn manifest_entry_parses_explicit_key_type() {
1982 let json = r#"{"key_id": "byok.b", "key_type": "rsa-2048", "pkcs8_file": "/tmp/b.der"}"#;
1983 let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1984 assert_eq!(entry.key_type, super::KeyTypeArg::Rsa2048);
1985 assert_eq!(entry.pkcs8_file.as_deref(), Some(Path::new("/tmp/b.der")));
1986
1987 let json =
1988 r#"{"key_id": "byok.ecdsa", "key_type": "ecdsa-p256", "pkcs8_file": "/tmp/e.der"}"#;
1989 let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1990 assert_eq!(entry.key_type, super::KeyTypeArg::EcdsaP256);
1991 assert_eq!(entry.pkcs8_file.as_deref(), Some(Path::new("/tmp/e.der")));
1992 }
1993
1994 #[test]
1995 fn import_set_check_validates_manifest_and_local_material() {
1996 let base =
1997 std::env::temp_dir().join(format!("basil-import-set-check-{}", std::process::id()));
1998 std::fs::create_dir_all(&base).expect("test temp directory must be created");
1999 let der = base.join("key.der");
2000 std::fs::write(&der, [0x30, 0x03, 0x02, 0x01, 0x00]).expect("DER fixture must be written");
2001 let manifest = base.join("manifest.json");
2002 std::fs::write(
2003 &manifest,
2004 format!(
2005 r#"[
2006 {{"key_id":"byok.seed","seed_hex":"{}"}},
2007 {{"key_id":"byok.pkcs8","key_type":"rsa-2048","pkcs8_file":"{}"}}
2008 ]"#,
2009 "11".repeat(32),
2010 der.display()
2011 ),
2012 )
2013 .expect("manifest fixture must be written");
2014
2015 check_import_set(&manifest).expect("valid manifest must pass check mode validation");
2016 }
2017
2018 #[test]
2019 fn import_set_check_rejects_empty_pkcs8_file() {
2020 let base = std::env::temp_dir().join(format!(
2021 "basil-import-set-check-empty-{}",
2022 std::process::id()
2023 ));
2024 std::fs::create_dir_all(&base).expect("test temp directory must be created");
2025 let der = base.join("empty.der");
2026 std::fs::write(&der, []).expect("empty DER fixture must be written");
2027 let manifest = base.join("manifest.json");
2028 std::fs::write(
2029 &manifest,
2030 format!(
2031 r#"[{{"key_id":"byok.pkcs8","key_type":"rsa-2048","pkcs8_file":"{}"}}]"#,
2032 der.display()
2033 ),
2034 )
2035 .expect("manifest fixture must be written");
2036
2037 assert!(
2038 check_import_set(&manifest).is_err(),
2039 "empty PKCS#8 files are rejected in check mode"
2040 );
2041 }
2042
2043 #[tokio::test]
2044 async fn import_checks_do_not_connect_to_agent() {
2045 super::run(
2046 Some("/tmp/basil-check-mode-no-agent.sock".to_string()),
2047 ClientCommand::Import {
2048 key_id: "byok.signer".to_string(),
2049 key_type: super::KeyTypeArg::Ed25519,
2050 seed_hex: Some("22".repeat(32)),
2051 pkcs8_file: None,
2052 check: true,
2053 },
2054 )
2055 .await
2056 .expect("import --check must not require a live agent");
2057
2058 let base = std::env::temp_dir().join(format!(
2059 "basil-import-set-no-connect-{}",
2060 std::process::id()
2061 ));
2062 std::fs::create_dir_all(&base).expect("test temp directory must be created");
2063 let manifest = base.join("manifest.json");
2064 std::fs::write(
2065 &manifest,
2066 format!(
2067 r#"[{{"key_id":"byok.seed","seed_hex":"{}"}}]"#,
2068 "33".repeat(32)
2069 ),
2070 )
2071 .expect("manifest fixture must be written");
2072
2073 super::run(
2074 Some("/tmp/basil-check-mode-no-agent.sock".to_string()),
2075 ClientCommand::ImportSet {
2076 file: manifest,
2077 check: true,
2078 },
2079 )
2080 .await
2081 .expect("import-set --check must not require a live agent");
2082 }
2083
2084 #[test]
2092 fn health_json_shape_is_alive_and_version() {
2093 let h = AgentHealth {
2094 alive: true,
2095 version: "0.1.0".to_string(),
2096 };
2097 let v = health_json(&h);
2098 assert_eq!(v["alive"], serde_json::json!(true));
2100 assert_eq!(v["version"], serde_json::json!("0.1.0"));
2101 let obj = v.as_object().expect("health --json is a JSON object");
2102 assert_eq!(
2103 obj.len(),
2104 2,
2105 "health --json carries exactly alive + version"
2106 );
2107 }
2108
2109 fn readiness(ready: bool, reason: ReadinessReason, present: u32, total: u32) -> AgentReadiness {
2111 AgentReadiness {
2112 ready,
2113 reason,
2114 generation: 7,
2115 keys_total: total,
2116 keys_present: present,
2117 keys_required_missing: total - present,
2118 keys_optional_missing: 0,
2119 }
2120 }
2121
2122 #[test]
2123 fn ready_json_shape_and_reason_tokens() {
2124 let r = readiness(true, ReadinessReason::Ready, 4, 4);
2126 let v = ready_json(&r);
2127 assert_eq!(v["ready"], serde_json::json!(true));
2128 assert_eq!(v["reason"], serde_json::json!("ready"));
2129 assert_eq!(v["generation"], serde_json::json!(7));
2130 assert_eq!(v["keys_total"], serde_json::json!(4));
2131 assert_eq!(v["keys_present"], serde_json::json!(4));
2132 assert_eq!(v["keys_required_missing"], serde_json::json!(0));
2133 assert_eq!(v["keys_optional_missing"], serde_json::json!(0));
2134 let obj = v.as_object().expect("ready --json is a JSON object");
2135 assert_eq!(
2136 obj.len(),
2137 7,
2138 "ready --json carries the full 7-field summary"
2139 );
2140
2141 assert_eq!(
2143 ready_json(&readiness(false, ReadinessReason::BackendUnreachable, 0, 4))["reason"],
2144 serde_json::json!("backend_unreachable")
2145 );
2146 assert_eq!(
2147 ready_json(&readiness(false, ReadinessReason::RequiredKeyMissing, 3, 4))["reason"],
2148 serde_json::json!("required_key_missing")
2149 );
2150 }
2151
2152 #[test]
2153 fn ready_exit_code_maps_ready_to_zero_and_not_ready_to_one() {
2154 assert_eq!(
2155 ready_exit_code(&readiness(true, ReadinessReason::Ready, 4, 4)),
2156 0,
2157 "a ready broker exits 0"
2158 );
2159 assert_eq!(
2160 ready_exit_code(&readiness(false, ReadinessReason::BackendUnreachable, 0, 4)),
2161 1,
2162 "a backend-unreachable broker exits 1 (not ready)"
2163 );
2164 assert_eq!(
2165 ready_exit_code(&readiness(false, ReadinessReason::RequiredKeyMissing, 3, 4)),
2166 1,
2167 "a required-key-missing broker exits 1 (not ready)"
2168 );
2169 }
2170
2171 fn reload_applied() -> AgentReload {
2173 AgentReload {
2174 applied: true,
2175 checked: false,
2176 previous_generation: 4,
2177 new_generation: 5,
2178 key_count: 12,
2179 grant_count: 9,
2180 rejection: None,
2181 }
2182 }
2183
2184 fn reload_rejected() -> AgentReload {
2186 AgentReload {
2187 applied: false,
2188 checked: false,
2189 previous_generation: 4,
2190 new_generation: 4,
2191 key_count: 12,
2192 grant_count: 9,
2193 rejection: Some(ReloadRejection {
2194 reason: "routing_shape_changed".to_string(),
2195 message: "a key's backend locator changed (restart-only)".to_string(),
2196 }),
2197 }
2198 }
2199
2200 #[test]
2201 fn reload_json_shape_applied_has_null_rejection() {
2202 let v = reload_json(&reload_applied());
2203 assert_eq!(v["applied"], serde_json::json!(true));
2204 assert_eq!(v["checked"], serde_json::json!(false));
2205 assert_eq!(v["previous_generation"], serde_json::json!(4));
2206 assert_eq!(v["new_generation"], serde_json::json!(5));
2207 assert_eq!(v["key_count"], serde_json::json!(12));
2208 assert_eq!(v["grant_count"], serde_json::json!(9));
2209 assert_eq!(v["rejection"], serde_json::Value::Null);
2212 let obj = v.as_object().expect("reload --json is a JSON object");
2213 assert_eq!(obj.len(), 7, "reload --json carries 7 keys incl. rejection");
2214 }
2215
2216 #[test]
2217 fn reload_json_shape_rejected_nests_reason_and_message() {
2218 let v = reload_json(&reload_rejected());
2219 assert_eq!(v["applied"], serde_json::json!(false));
2220 assert_eq!(v["previous_generation"], v["new_generation"]);
2222 let rej = v["rejection"]
2223 .as_object()
2224 .expect("a rejected reload nests a rejection object");
2225 assert_eq!(rej["reason"], serde_json::json!("routing_shape_changed"));
2226 assert_eq!(
2227 rej["message"],
2228 serde_json::json!("a key's backend locator changed (restart-only)")
2229 );
2230 }
2231
2232 #[test]
2233 fn reload_exit_code_maps_accepted_to_zero_and_rejected_to_one() {
2234 assert_eq!(
2235 reload_exit_code(&reload_applied()),
2236 0,
2237 "an applied reload exits 0"
2238 );
2239 assert_eq!(
2240 reload_exit_code(&reload_rejected()),
2241 1,
2242 "a rejected reload exits 1 (never a silent 0)"
2243 );
2244 let mut dry = reload_applied();
2246 dry.applied = false;
2247 dry.checked = true;
2248 assert_eq!(reload_exit_code(&dry), 0, "a clean --check dry-run exits 0");
2249 }
2250
2251 #[test]
2252 fn revoke_json_shape_is_stable() {
2253 let v = revoke_json(&AgentRevocation {
2254 trust_domain: "example.test".into(),
2255 jti: "token-1".into(),
2256 expires_at_unix: 2_000_000_000,
2257 persisted: true,
2258 });
2259 assert_eq!(v["trust_domain"], serde_json::json!("example.test"));
2260 assert_eq!(v["jti"], serde_json::json!("token-1"));
2261 assert_eq!(v["expires_at_unix"], serde_json::json!(2_000_000_000_u64));
2262 assert_eq!(v["persisted"], serde_json::json!(true));
2263 let obj = v.as_object().expect("revoke --json is a JSON object");
2264 assert_eq!(obj.len(), 4, "revoke --json carries exactly four fields");
2265 }
2266}