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