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