1use rmcp::schemars::JsonSchema;
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use tailscale_cli::Invocation;
55
56use crate::cli;
57use crate::context::ToolContext;
58use crate::error::{ErrorCode, ToolError, ToolResult};
59use crate::tools::common::{
60 flag, note, printed, push_bool, push_text, real_path, report, secret_value, tokens_with_prefix,
61};
62
63crate::tools! {
64 tailscale_lock_init => LockInitParams, lock_init,
71 toolset: LocalLock, tier: Destructive, confirm: true;
72
73 tailscale_lock_add => LockKeysParams, lock_add,
77 toolset: LocalLock, tier: Write, idempotent: true;
78
79 tailscale_lock_remove => LockRemoveParams, lock_remove,
84 toolset: LocalLock, tier: Destructive;
85
86 tailscale_lock_sign => LockSignParams, lock_sign,
91 toolset: LocalLock, tier: Write;
92
93 tailscale_lock_disable => LockDisableParams, lock_disable,
99 toolset: LocalLock, tier: Destructive, confirm: true;
100
101 tailscale_lock_disablement_kdf => LockDisablementKdfParams, lock_disablement_kdf,
106 toolset: LocalLock, tier: Read, idempotent: true;
107
108 tailscale_lock_local_disable => NoParams, lock_local_disable,
112 toolset: LocalLock, tier: Destructive;
113
114 tailscale_lock_revoke_keys => LockRevokeKeysParams, lock_revoke_keys,
121 toolset: LocalLock, tier: Destructive, confirm: true;
122}
123
124const KEY_PREFIX: &str = "tlpub:";
133
134const AUTH_KEY_PREFIX: &str = "tskey-";
138
139const DISABLEMENT_SECRET_PREFIX: &str = "disablement-secret:";
141
142const DISABLEMENT_VALUE_PREFIX: &str = "disablement:";
145
146#[derive(Debug, Deserialize, JsonSchema)]
148pub struct NoParams {}
149
150const fn default_gen_disablements() -> u32 {
155 1
156}
157
158#[derive(Debug, Deserialize, JsonSchema)]
159pub struct LockInitParams {
160 pub trusted_keys: Vec<String>,
164 #[serde(default = "default_gen_disablements")]
167 pub gen_disablements: u32,
168 #[serde(default)]
172 pub gen_disablement_for_support: bool,
173}
174
175#[derive(Debug, Default, Deserialize, JsonSchema)]
176pub struct LockKeysParams {
177 pub keys: Vec<String>,
179}
180
181#[derive(Debug, Default, Deserialize, JsonSchema)]
182pub struct LockRemoveParams {
183 pub keys: Vec<String>,
185 #[serde(default)]
189 pub re_sign: Option<bool>,
190}
191
192#[derive(Debug, Default, Deserialize, JsonSchema)]
193pub struct LockSignParams {
194 pub key: String,
199 #[serde(default)]
202 pub rotation_key: Option<String>,
203}
204
205#[derive(Debug, Default, Deserialize, JsonSchema)]
206pub struct LockDisableParams {
207 pub secret: String,
211}
212
213#[derive(Debug, Default, Deserialize, JsonSchema)]
214pub struct LockDisablementKdfParams {
215 pub secret: String,
218}
219
220#[derive(Debug, Default, Deserialize, JsonSchema)]
221pub struct LockRevokeKeysParams {
222 #[serde(default)]
225 pub keys: Vec<String>,
226 #[serde(default)]
229 pub recovery_blob: Option<String>,
230 #[serde(default)]
233 pub cosign: bool,
234 #[serde(default)]
237 pub finish: bool,
238 #[serde(default)]
241 pub fork_from: Option<String>,
242}
243
244#[derive(Debug, Serialize, JsonSchema)]
250pub struct LockReport {
251 pub outcome: String,
253 #[serde(skip_serializing_if = "Vec::is_empty")]
257 pub keys: Vec<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
261 pub printed: Option<String>,
262}
263
264#[derive(Debug, Serialize, JsonSchema)]
267pub struct StateReport {
268 pub outcome: String,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub printed: Option<String>,
272}
273
274#[derive(Debug, Serialize, JsonSchema)]
275pub struct InitReport {
276 pub outcome: String,
277 pub trusted_keys: Vec<String>,
279 #[serde(skip_serializing_if = "Vec::is_empty")]
284 pub disablement_secrets: Vec<String>,
285 pub gen_disablement_for_support: bool,
287 #[serde(skip_serializing_if = "Option::is_none")]
291 pub printed: Option<String>,
292}
293
294#[derive(Debug, Serialize, JsonSchema)]
295pub struct SignReport {
296 pub outcome: String,
297 #[serde(skip_serializing_if = "Option::is_none")]
301 pub signed_auth_key: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub printed: Option<String>,
304}
305
306#[derive(Debug, Serialize, JsonSchema)]
307pub struct DisablementReport {
308 pub disablement_value: String,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub note: Option<String>,
314}
315
316fn public_keys(keys: &[String], what: &str) -> ToolResult<Vec<String>> {
327 let trimmed: Vec<String> = keys
328 .iter()
329 .map(|key| key.trim().to_owned())
330 .filter(|key| !key.is_empty())
331 .collect();
332 if trimmed.is_empty() {
333 return Err(ToolError::invalid_args(format!(
334 "`{what}` needs at least one tailnet-lock key"
335 )));
336 }
337 if let Some(nth) = trimmed.iter().position(|key| !key.starts_with(KEY_PREFIX)) {
338 return Err(ToolError::invalid_args(format!(
339 "value {} of `{what}` is not a tailnet-lock key: they begin with `{KEY_PREFIX}`",
340 nth + 1
341 ))
342 .with_hint("`tailscale_lock_status` reports this node's own tailnet-lock key."));
343 }
344 Ok(trimmed)
345}
346
347fn disablement_secret(ctx: &ToolContext, what: &str, value: &str) -> ToolResult<String> {
357 let value = value.trim();
358 if value.is_empty() {
359 return Err(ToolError::invalid_args(format!("`{what}` cannot be empty")));
360 }
361 let Some(path) = value.strip_prefix("file:") else {
362 return Ok(value.to_owned());
363 };
364 let path = real_path(ctx, what, path)?;
365 let text = std::fs::read_to_string(&path).map_err(|e| {
366 let code = match e.kind() {
367 std::io::ErrorKind::NotFound => ErrorCode::NotFound,
368 std::io::ErrorKind::PermissionDenied => ErrorCode::NotPermitted,
369 _ => ErrorCode::CliFailed,
370 };
371 ToolError::new(
372 code,
373 format!("`{what}` names a file that could not be read: {e}"),
374 )
375 })?;
376 let text = text.trim();
377 if text.is_empty() {
378 return Err(ToolError::invalid_args(format!(
379 "`{what}` names a file with nothing in it"
380 )));
381 }
382 Ok(text.to_owned())
383}
384
385async fn lock_init(ctx: &ToolContext, params: LockInitParams) -> ToolResult<Value> {
390 let meta = &metas::tailscale_lock_init;
391 let trusted_keys = public_keys(¶ms.trusted_keys, "trusted_keys")?;
392 if params.gen_disablements == 0 {
396 return Err(ToolError::invalid_args(
397 "`gen_disablements` has to be at least 1: a lock with no disablement secret can never be turned off",
398 ));
399 }
400
401 let mut args = vec![
402 "lock".to_owned(),
403 "init".to_owned(),
404 flag("confirm", true),
406 flag(
407 "gen-disablement-for-support",
408 params.gen_disablement_for_support,
409 ),
410 format!("--gen-disablements={}", params.gen_disablements),
411 ];
412 args.extend(trusted_keys.iter().cloned());
413
414 let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
415 let printed_text = output.stdout_str();
416 report(InitReport {
417 outcome: format!(
418 "tailnet lock is enabled for the tailnet, trusting {} key(s)",
419 trusted_keys.len()
420 ),
421 trusted_keys,
422 disablement_secrets: tokens_with_prefix(&printed_text, &[DISABLEMENT_SECRET_PREFIX]),
423 gen_disablement_for_support: params.gen_disablement_for_support,
424 printed: printed(ctx, &output),
425 })
426}
427
428async fn lock_add(ctx: &ToolContext, params: LockKeysParams) -> ToolResult<Value> {
429 let meta = &metas::tailscale_lock_add;
430 let keys = public_keys(¶ms.keys, "keys")?;
431 let mut args = vec!["lock".to_owned(), "add".to_owned()];
432 args.extend(keys.iter().cloned());
433
434 let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
435 report(LockReport {
436 outcome: "tailnet lock now trusts these keys to sign nodes and to change the lock"
437 .to_owned(),
438 keys,
439 printed: printed(ctx, &output),
440 })
441}
442
443async fn lock_remove(ctx: &ToolContext, params: LockRemoveParams) -> ToolResult<Value> {
444 let meta = &metas::tailscale_lock_remove;
445 let keys = public_keys(¶ms.keys, "keys")?;
446 let mut args = vec!["lock".to_owned(), "remove".to_owned()];
447 push_bool(&mut args, "re-sign", params.re_sign);
448 args.extend(keys.iter().cloned());
449
450 let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
451 let outcome = if params.re_sign == Some(false) {
452 "tailnet lock no longer trusts these keys, and the nodes they signed are locked out"
453 } else {
454 "tailnet lock no longer trusts these keys; the nodes they signed were re-signed and stay admitted"
455 };
456 report(LockReport {
457 outcome: outcome.to_owned(),
458 keys,
459 printed: printed(ctx, &output),
460 })
461}
462
463async fn lock_sign(ctx: &ToolContext, params: LockSignParams) -> ToolResult<Value> {
464 let meta = &metas::tailscale_lock_sign;
465 let key = params.key.trim();
466 if key.is_empty() {
467 return Err(ToolError::invalid_args("`key` cannot be empty"));
468 }
469 let (key_value, key_file) = secret_value("key", key)?;
470 let mut args = vec!["lock".to_owned(), "sign".to_owned(), key_value];
471
472 let rotation = params
473 .rotation_key
474 .as_deref()
475 .map(str::trim)
476 .filter(|value| !value.is_empty());
477 let rotation_file = match rotation {
478 Some(rotation) => {
479 let (value, file) = secret_value("rotation_key", rotation)?;
480 args.push(value);
481 file
482 }
483 None => None,
484 };
485
486 let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
487 drop(key_file);
488 drop(rotation_file);
489
490 let signed_auth_key = tokens_with_prefix(&output.stdout_str(), &[AUTH_KEY_PREFIX])
494 .into_iter()
495 .next();
496 let (outcome, printed_text) = match &signed_auth_key {
497 Some(_) => (
501 "the auth key is signed and can now bring nodes up under tailnet lock",
502 note(ctx, &output.stderr),
503 ),
504 None => (
505 "the node key is signed and the signature is with the control plane",
506 printed(ctx, &output),
507 ),
508 };
509 report(SignReport {
510 outcome: outcome.to_owned(),
511 signed_auth_key,
512 printed: printed_text,
513 })
514}
515
516async fn lock_disable(ctx: &ToolContext, params: LockDisableParams) -> ToolResult<Value> {
517 let meta = &metas::tailscale_lock_disable;
518 let secret = disablement_secret(ctx, "secret", ¶ms.secret)?;
519 if !secret.starts_with(DISABLEMENT_SECRET_PREFIX)
520 && !secret.starts_with(DISABLEMENT_VALUE_PREFIX)
521 {
522 return Err(ToolError::invalid_args(format!(
523 "`secret` has to carry its `{DISABLEMENT_SECRET_PREFIX}` or `{DISABLEMENT_VALUE_PREFIX}` prefix, which says which half of the disablement it is"
524 )));
525 }
526
527 let output = cli::run(
528 ctx,
529 meta,
530 Invocation::mutate(["lock".to_owned(), "disable".to_owned(), secret]),
531 )
532 .await?;
533 report(StateReport {
534 outcome: "tailnet lock is off for the whole tailnet, and the secret that turned it off is now public".to_owned(),
535 printed: printed(ctx, &output),
536 })
537}
538
539async fn lock_disablement_kdf(
540 ctx: &ToolContext,
541 params: LockDisablementKdfParams,
542) -> ToolResult<Value> {
543 let meta = &metas::tailscale_lock_disablement_kdf;
544 let secret = disablement_secret(ctx, "secret", ¶ms.secret)?;
545 let hex = secret
550 .strip_prefix(DISABLEMENT_SECRET_PREFIX)
551 .unwrap_or(&secret);
552 if hex.is_empty() || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
553 return Err(ToolError::invalid_args(
554 "`secret` has to be a hex-encoded disablement secret, with or without its `disablement-secret:` prefix",
555 )
556 .with_hint(
557 "A `disablement:` value is the public half and is what this computes, not what it takes.",
558 ));
559 }
560
561 let output = cli::run(
562 ctx,
563 meta,
564 Invocation::read([
565 "lock".to_owned(),
566 "disablement-kdf".to_owned(),
567 hex.to_owned(),
568 ]),
569 )
570 .await?;
571 let text = output.stdout_str();
572 let disablement_value = tokens_with_prefix(&text, &[DISABLEMENT_VALUE_PREFIX])
573 .into_iter()
574 .next()
575 .unwrap_or_else(|| text.trim().to_owned());
576 report(DisablementReport {
577 disablement_value,
578 note: note(ctx, &output.stderr),
579 })
580}
581
582async fn lock_local_disable(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
583 let meta = &metas::tailscale_lock_local_disable;
584 let output = cli::run(ctx, meta, Invocation::mutate(["lock", "local-disable"])).await?;
585 report(StateReport {
586 outcome: "this node now accepts traffic from nodes tailnet lock has locked out; the tailnet's lock is untouched".to_owned(),
587 printed: printed(ctx, &output),
588 })
589}
590
591async fn lock_revoke_keys(ctx: &ToolContext, params: LockRevokeKeysParams) -> ToolResult<Value> {
592 let meta = &metas::tailscale_lock_revoke_keys;
593 if params.cosign && params.finish {
594 return Err(ToolError::invalid_args(
595 "`cosign` and `finish` are the two ways of continuing a revocation and only one applies to a call: `cosign` on each further signing node, `finish` once there are more co-signatures than keys",
596 ));
597 }
598
599 let mut args = vec!["lock".to_owned(), "revoke-keys".to_owned()];
600 if params.cosign {
601 args.push(flag("cosign", true));
602 }
603 if params.finish {
604 args.push(flag("finish", true));
605 }
606 push_text(&mut args, "fork-from", params.fork_from.as_deref());
607
608 let blob = params
609 .recovery_blob
610 .as_deref()
611 .map(str::trim)
612 .filter(|blob| !blob.is_empty());
613 let keys = match blob {
614 Some(blob) => {
615 if !params.keys.is_empty() {
616 return Err(ToolError::invalid_args(
617 "name `keys` to start a revocation or `recovery_blob` to continue one, not both",
618 ));
619 }
620 args.push(blob.to_owned());
621 Vec::new()
622 }
623 None => {
624 if params.cosign || params.finish {
625 return Err(ToolError::invalid_args(
626 "`cosign` and `finish` continue a revocation, so they need the `recovery_blob` the step before printed",
627 ));
628 }
629 let keys = public_keys(¶ms.keys, "keys")?;
630 args.extend(keys.iter().cloned());
631 keys
632 }
633 };
634
635 let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
636 let outcome = if params.finish {
637 "the keys are revoked: every node they signed has lost its authorisation and has to be signed again"
638 } else if params.cosign {
639 "this node has co-signed the revocation; run the printed command on the next signing node, or finish once the co-signatures outnumber the keys"
640 } else {
641 "the revocation has begun; run the printed command on the next signing node to co-sign it"
642 };
643 report(LockReport {
644 outcome: outcome.to_owned(),
645 keys,
646 printed: printed(ctx, &output),
647 })
648}
649
650#[cfg(test)]
651mod tests {
652 use std::io::Write;
653 use std::sync::Arc;
654
655 use super::*;
656
657 use crate::testing::{Reply, StubBackend, context};
658
659 const TLPUB: &str = "tlpub:0000000000000000000000000000000000000000000000000000000000000000";
662 const OTHER: &str = "tlpub:1111111111111111111111111111111111111111111111111111111111111111";
663 const NODEKEY: &str =
664 "nodekey:0000000000000000000000000000000000000000000000000000000000000000";
665 const HEX: &str = "00112233445566778899aabbccddeeff";
666
667 async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
670 where
671 F: FnOnce(ToolContext, P) -> Fut,
672 Fut: Future<Output = ToolResult<Value>>,
673 {
674 let backend = Arc::new(StubBackend::always(reply));
675 let ctx = context(Arc::clone(&backend));
676 let value = handler(ctx, params).await.expect("the handler succeeds");
677 (value, backend.argv())
678 }
679
680 async fn refused<F, P, Fut>(handler: F, params: P) -> ToolError
683 where
684 F: FnOnce(ToolContext, P) -> Fut,
685 Fut: Future<Output = ToolResult<Value>>,
686 {
687 let backend = Arc::new(StubBackend::always(Reply::ok("")));
688 let ctx = context(Arc::clone(&backend));
689 let error = handler(ctx, params).await.expect_err("the handler refuses");
690 assert!(
691 backend.argv().is_empty(),
692 "nothing should have run: {:?}",
693 backend.argv()
694 );
695 error
696 }
697
698 fn only(argv: &[Vec<String>]) -> &[String] {
700 assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
701 &argv[0]
702 }
703
704 #[tokio::test]
710 async fn initialising_always_confirms_and_states_every_flag() {
711 let (_, argv) = against(
712 Reply::ok(""),
713 |ctx, p| async move { lock_init(&ctx, p).await },
714 LockInitParams {
715 trusted_keys: vec![TLPUB.to_owned(), OTHER.to_owned()],
716 gen_disablements: 3,
717 gen_disablement_for_support: true,
718 },
719 )
720 .await;
721
722 assert_eq!(
723 only(&argv),
724 [
725 "lock",
726 "init",
727 "--confirm=true",
728 "--gen-disablement-for-support=true",
729 "--gen-disablements=3",
730 TLPUB,
731 OTHER,
732 ]
733 );
734 }
735
736 #[tokio::test]
741 async fn initialising_reads_the_minted_secrets_out_of_what_was_printed() {
742 let printed = format!(
743 "Tailnet lock is now enabled.\n\
744 Disablement secrets:\n disablement-secret:{HEX}\n disablement-secret:ffee\n"
745 );
746 let (answer, _) = against(
747 Reply::ok(printed),
748 |ctx, p| async move { lock_init(&ctx, p).await },
749 LockInitParams {
750 trusted_keys: vec![TLPUB.to_owned()],
751 gen_disablements: 1,
752 gen_disablement_for_support: false,
753 },
754 )
755 .await;
756
757 assert_eq!(
758 answer["disablement_secrets"],
759 serde_json::json!([
760 format!("disablement-secret:{HEX}"),
761 "disablement-secret:ffee"
762 ])
763 );
764 assert!(
765 answer["printed"]
766 .as_str()
767 .is_some_and(|text| text.contains(HEX)),
768 "the client's own text is kept as well: {answer}"
769 );
770 }
771
772 #[tokio::test]
773 async fn a_lock_with_no_way_back_is_refused() {
774 let error = refused(
775 |ctx, p| async move { lock_init(&ctx, p).await },
776 LockInitParams {
777 trusted_keys: vec![TLPUB.to_owned()],
778 gen_disablements: 0,
779 gen_disablement_for_support: false,
780 },
781 )
782 .await;
783 assert_eq!(error.code, ErrorCode::InvalidArgs);
784 }
785
786 #[tokio::test]
789 async fn adding_keys_puts_them_after_the_subcommand() {
790 let (answer, argv) = against(
791 Reply::ok(""),
792 |ctx, p| async move { lock_add(&ctx, p).await },
793 LockKeysParams {
794 keys: vec![TLPUB.to_owned(), OTHER.to_owned()],
795 },
796 )
797 .await;
798
799 assert_eq!(only(&argv), ["lock", "add", TLPUB, OTHER]);
800 assert_eq!(answer["keys"], serde_json::json!([TLPUB, OTHER]));
801 }
802
803 #[tokio::test]
807 async fn a_key_of_the_wrong_kind_is_named_by_position_and_not_by_value() {
808 let error = refused(
809 |ctx, p| async move { lock_add(&ctx, p).await },
810 LockKeysParams {
811 keys: vec![
812 TLPUB.to_owned(),
813 "tskey-auth-example-secretvalue".to_owned(),
814 ],
815 },
816 )
817 .await;
818
819 assert_eq!(error.code, ErrorCode::InvalidArgs);
820 assert!(error.message.contains("value 2"), "{}", error.message);
821 assert!(
822 !error.message.contains("tskey-auth-example-secretvalue"),
823 "the rejected value is not repeated: {}",
824 error.message
825 );
826 }
827
828 #[tokio::test]
829 async fn no_keys_at_all_is_refused() {
830 let error = refused(
831 |ctx, p| async move { lock_add(&ctx, p).await },
832 LockKeysParams::default(),
833 )
834 .await;
835 assert_eq!(error.code, ErrorCode::InvalidArgs);
836 }
837
838 #[tokio::test]
841 async fn removing_keys_mentions_re_signing_only_when_it_was_asked_about() {
842 let (answer, argv) = against(
843 Reply::ok(""),
844 |ctx, p| async move { lock_remove(&ctx, p).await },
845 LockRemoveParams {
846 keys: vec![TLPUB.to_owned()],
847 re_sign: None,
848 },
849 )
850 .await;
851 assert_eq!(only(&argv), ["lock", "remove", TLPUB]);
852 assert!(
853 answer["outcome"]
854 .as_str()
855 .is_some_and(|text| text.contains("stay admitted")),
856 "{answer}"
857 );
858
859 let (answer, argv) = against(
860 Reply::ok(""),
861 |ctx, p| async move { lock_remove(&ctx, p).await },
862 LockRemoveParams {
863 keys: vec![TLPUB.to_owned()],
864 re_sign: Some(false),
865 },
866 )
867 .await;
868 assert_eq!(only(&argv), ["lock", "remove", "--re-sign=false", TLPUB]);
869 assert!(
870 answer["outcome"]
871 .as_str()
872 .is_some_and(|text| text.contains("locked out")),
873 "{answer}"
874 );
875 }
876
877 #[tokio::test]
880 async fn a_key_given_directly_reaches_the_client_as_a_private_file() {
881 let (_, argv) = against(
882 Reply::ok(""),
883 |ctx, p| async move { lock_sign(&ctx, p).await },
884 LockSignParams {
885 key: NODEKEY.to_owned(),
886 rotation_key: None,
887 },
888 )
889 .await;
890
891 let argv = only(&argv);
892 assert_eq!(&argv[..2], ["lock", "sign"]);
893 assert!(
894 argv[2].starts_with("file:") && argv[2].ends_with(".key"),
895 "the key itself must not reach the argument list: {argv:?}"
896 );
897 }
898
899 #[tokio::test]
902 async fn a_file_reference_is_passed_through_as_it_stands() {
903 let (_, argv) = against(
904 Reply::ok(""),
905 |ctx, p| async move { lock_sign(&ctx, p).await },
906 LockSignParams {
907 key: "file:/var/keys/node.key".to_owned(),
908 rotation_key: Some(NODEKEY.to_owned()),
909 },
910 )
911 .await;
912
913 let argv = only(&argv);
914 assert_eq!(argv[2], "file:/var/keys/node.key");
915 assert!(
916 argv[3].starts_with("file:") && argv[3].ends_with(".key"),
917 "the rotation key gets a private file of its own: {argv:?}"
918 );
919 }
920
921 #[tokio::test]
924 async fn a_signed_auth_key_comes_back_whole_and_only_once() {
925 let signed = "tskey-auth-example0CNTRL-signedvalue";
926 let (answer, _) = against(
927 Reply::ok(format!("{signed}\n")),
928 |ctx, p| async move { lock_sign(&ctx, p).await },
929 LockSignParams {
930 key: "tskey-auth-example0CNTRL".to_owned(),
931 rotation_key: None,
932 },
933 )
934 .await;
935
936 assert_eq!(answer["signed_auth_key"], signed);
937 assert!(
938 answer["printed"].is_null(),
939 "the key is carried once, not twice: {answer}"
940 );
941 }
942
943 #[tokio::test]
944 async fn signing_a_node_key_reports_no_auth_key() {
945 let (answer, _) = against(
946 Reply::ok(""),
947 |ctx, p| async move { lock_sign(&ctx, p).await },
948 LockSignParams {
949 key: NODEKEY.to_owned(),
950 rotation_key: None,
951 },
952 )
953 .await;
954
955 assert!(answer["signed_auth_key"].is_null(), "{answer}");
956 assert!(
957 answer["outcome"]
958 .as_str()
959 .is_some_and(|text| text.contains("node key")),
960 "{answer}"
961 );
962 }
963
964 #[tokio::test]
965 async fn an_empty_key_is_refused() {
966 let error = refused(
967 |ctx, p| async move { lock_sign(&ctx, p).await },
968 LockSignParams {
969 key: " ".to_owned(),
970 rotation_key: None,
971 },
972 )
973 .await;
974 assert_eq!(error.code, ErrorCode::InvalidArgs);
975 }
976
977 #[tokio::test]
982 async fn a_disablement_without_its_prefix_is_refused() {
983 let error = refused(
984 |ctx, p| async move { lock_disable(&ctx, p).await },
985 LockDisableParams {
986 secret: HEX.to_owned(),
987 },
988 )
989 .await;
990 assert_eq!(error.code, ErrorCode::InvalidArgs);
991 }
992
993 #[tokio::test]
997 async fn a_disablement_secret_can_be_kept_in_a_file() {
998 let mut file = tempfile::NamedTempFile::new().expect("a temporary file");
999 writeln!(file, "disablement-secret:{HEX}").expect("the secret is written");
1000 let path = file.path().display().to_string();
1001
1002 let (_, argv) = against(
1003 Reply::ok(""),
1004 |ctx, p| async move { lock_disable(&ctx, p).await },
1005 LockDisableParams {
1006 secret: format!("file:{path}"),
1007 },
1008 )
1009 .await;
1010
1011 assert_eq!(
1012 only(&argv),
1013 ["lock", "disable", &format!("disablement-secret:{HEX}")]
1014 );
1015 }
1016
1017 #[tokio::test]
1018 async fn a_file_that_is_not_there_is_not_found() {
1019 let error = refused(
1020 |ctx, p| async move { lock_disable(&ctx, p).await },
1021 LockDisableParams {
1022 secret: "file:/nowhere/at/all/secret.txt".to_owned(),
1023 },
1024 )
1025 .await;
1026 assert_eq!(error.code, ErrorCode::NotFound);
1027 }
1028
1029 #[tokio::test]
1032 async fn the_kdf_takes_the_prefix_off_a_minted_secret() {
1033 let (answer, argv) = against(
1034 Reply::ok("disablement:756fe19f200fbfc9ad431e75c7942b82\n"),
1035 |ctx, p| async move { lock_disablement_kdf(&ctx, p).await },
1036 LockDisablementKdfParams {
1037 secret: format!("disablement-secret:{HEX}"),
1038 },
1039 )
1040 .await;
1041
1042 assert_eq!(only(&argv), ["lock", "disablement-kdf", HEX]);
1043 assert_eq!(
1044 answer["disablement_value"],
1045 "disablement:756fe19f200fbfc9ad431e75c7942b82"
1046 );
1047 }
1048
1049 #[tokio::test]
1050 async fn the_kdf_refuses_something_that_is_not_hex() {
1051 let error = refused(
1052 |ctx, p| async move { lock_disablement_kdf(&ctx, p).await },
1053 LockDisablementKdfParams {
1054 secret: "disablement:756fe1".to_owned(),
1055 },
1056 )
1057 .await;
1058 assert_eq!(error.code, ErrorCode::InvalidArgs);
1059 }
1060
1061 #[tokio::test]
1064 async fn starting_a_revocation_names_the_keys() {
1065 let (answer, argv) = against(
1066 Reply::ok("next: tailscale lock revoke-keys --cosign blob\n"),
1067 |ctx, p| async move { lock_revoke_keys(&ctx, p).await },
1068 LockRevokeKeysParams {
1069 keys: vec![TLPUB.to_owned()],
1070 ..LockRevokeKeysParams::default()
1071 },
1072 )
1073 .await;
1074
1075 assert_eq!(only(&argv), ["lock", "revoke-keys", TLPUB]);
1076 assert!(
1077 answer["printed"]
1078 .as_str()
1079 .is_some_and(|text| text.contains("--cosign")),
1080 "the command to run next is what the caller needs: {answer}"
1081 );
1082 }
1083
1084 #[tokio::test]
1085 async fn continuing_a_revocation_puts_the_blob_after_its_flags() {
1086 let (_, argv) = against(
1087 Reply::ok(""),
1088 |ctx, p| async move { lock_revoke_keys(&ctx, p).await },
1089 LockRevokeKeysParams {
1090 recovery_blob: Some("abcdef".to_owned()),
1091 cosign: true,
1092 fork_from: Some("aum-hash".to_owned()),
1093 ..LockRevokeKeysParams::default()
1094 },
1095 )
1096 .await;
1097
1098 assert_eq!(
1099 only(&argv),
1100 [
1101 "lock",
1102 "revoke-keys",
1103 "--cosign=true",
1104 "--fork-from=aum-hash",
1105 "abcdef",
1106 ]
1107 );
1108 }
1109
1110 #[tokio::test]
1111 async fn the_two_ways_of_continuing_are_not_both_at_once() {
1112 let error = refused(
1113 |ctx, p| async move { lock_revoke_keys(&ctx, p).await },
1114 LockRevokeKeysParams {
1115 recovery_blob: Some("abcdef".to_owned()),
1116 cosign: true,
1117 finish: true,
1118 ..LockRevokeKeysParams::default()
1119 },
1120 )
1121 .await;
1122 assert_eq!(error.code, ErrorCode::InvalidArgs);
1123 }
1124
1125 #[tokio::test]
1126 async fn continuing_without_the_blob_from_the_step_before_is_refused() {
1127 let error = refused(
1128 |ctx, p| async move { lock_revoke_keys(&ctx, p).await },
1129 LockRevokeKeysParams {
1130 keys: vec![TLPUB.to_owned()],
1131 finish: true,
1132 ..LockRevokeKeysParams::default()
1133 },
1134 )
1135 .await;
1136 assert_eq!(error.code, ErrorCode::InvalidArgs);
1137 }
1138
1139 #[tokio::test]
1140 async fn keys_and_a_blob_together_are_refused() {
1141 let error = refused(
1142 |ctx, p| async move { lock_revoke_keys(&ctx, p).await },
1143 LockRevokeKeysParams {
1144 keys: vec![TLPUB.to_owned()],
1145 recovery_blob: Some("abcdef".to_owned()),
1146 ..LockRevokeKeysParams::default()
1147 },
1148 )
1149 .await;
1150 assert_eq!(error.code, ErrorCode::InvalidArgs);
1151 }
1152}