use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tailscale_cli::Invocation;
use crate::cli;
use crate::context::ToolContext;
use crate::error::{ErrorCode, ToolError, ToolResult};
use crate::tools::common::{
flag, note, printed, push_bool, push_text, real_path, report, secret_value, tokens_with_prefix,
};
crate::tools! {
tailscale_lock_init => LockInitParams, lock_init,
toolset: LocalLock, tier: Destructive, confirm: true;
tailscale_lock_add => LockKeysParams, lock_add,
toolset: LocalLock, tier: Write, idempotent: true;
tailscale_lock_remove => LockRemoveParams, lock_remove,
toolset: LocalLock, tier: Destructive;
tailscale_lock_sign => LockSignParams, lock_sign,
toolset: LocalLock, tier: Write;
tailscale_lock_disable => LockDisableParams, lock_disable,
toolset: LocalLock, tier: Destructive, confirm: true;
tailscale_lock_disablement_kdf => LockDisablementKdfParams, lock_disablement_kdf,
toolset: LocalLock, tier: Read, idempotent: true;
tailscale_lock_local_disable => NoParams, lock_local_disable,
toolset: LocalLock, tier: Destructive;
tailscale_lock_revoke_keys => LockRevokeKeysParams, lock_revoke_keys,
toolset: LocalLock, tier: Destructive, confirm: true;
}
const KEY_PREFIX: &str = "tlpub:";
const AUTH_KEY_PREFIX: &str = "tskey-";
const DISABLEMENT_SECRET_PREFIX: &str = "disablement-secret:";
const DISABLEMENT_VALUE_PREFIX: &str = "disablement:";
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
const fn default_gen_disablements() -> u32 {
1
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LockInitParams {
pub trusted_keys: Vec<String>,
#[serde(default = "default_gen_disablements")]
pub gen_disablements: u32,
#[serde(default)]
pub gen_disablement_for_support: bool,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct LockKeysParams {
pub keys: Vec<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct LockRemoveParams {
pub keys: Vec<String>,
#[serde(default)]
pub re_sign: Option<bool>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct LockSignParams {
pub key: String,
#[serde(default)]
pub rotation_key: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct LockDisableParams {
pub secret: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct LockDisablementKdfParams {
pub secret: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct LockRevokeKeysParams {
#[serde(default)]
pub keys: Vec<String>,
#[serde(default)]
pub recovery_blob: Option<String>,
#[serde(default)]
pub cosign: bool,
#[serde(default)]
pub finish: bool,
#[serde(default)]
pub fork_from: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct LockReport {
pub outcome: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct StateReport {
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct InitReport {
pub outcome: String,
pub trusted_keys: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub disablement_secrets: Vec<String>,
pub gen_disablement_for_support: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct SignReport {
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signed_auth_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct DisablementReport {
pub disablement_value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
fn public_keys(keys: &[String], what: &str) -> ToolResult<Vec<String>> {
let trimmed: Vec<String> = keys
.iter()
.map(|key| key.trim().to_owned())
.filter(|key| !key.is_empty())
.collect();
if trimmed.is_empty() {
return Err(ToolError::invalid_args(format!(
"`{what}` needs at least one tailnet-lock key"
)));
}
if let Some(nth) = trimmed.iter().position(|key| !key.starts_with(KEY_PREFIX)) {
return Err(ToolError::invalid_args(format!(
"value {} of `{what}` is not a tailnet-lock key: they begin with `{KEY_PREFIX}`",
nth + 1
))
.with_hint("`tailscale_lock_status` reports this node's own tailnet-lock key."));
}
Ok(trimmed)
}
fn disablement_secret(ctx: &ToolContext, what: &str, value: &str) -> ToolResult<String> {
let value = value.trim();
if value.is_empty() {
return Err(ToolError::invalid_args(format!("`{what}` cannot be empty")));
}
let Some(path) = value.strip_prefix("file:") else {
return Ok(value.to_owned());
};
let path = real_path(ctx, what, path)?;
let text = std::fs::read_to_string(&path).map_err(|e| {
let code = match e.kind() {
std::io::ErrorKind::NotFound => ErrorCode::NotFound,
std::io::ErrorKind::PermissionDenied => ErrorCode::NotPermitted,
_ => ErrorCode::CliFailed,
};
ToolError::new(
code,
format!("`{what}` names a file that could not be read: {e}"),
)
})?;
let text = text.trim();
if text.is_empty() {
return Err(ToolError::invalid_args(format!(
"`{what}` names a file with nothing in it"
)));
}
Ok(text.to_owned())
}
async fn lock_init(ctx: &ToolContext, params: LockInitParams) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_init;
let trusted_keys = public_keys(¶ms.trusted_keys, "trusted_keys")?;
if params.gen_disablements == 0 {
return Err(ToolError::invalid_args(
"`gen_disablements` has to be at least 1: a lock with no disablement secret can never be turned off",
));
}
let mut args = vec![
"lock".to_owned(),
"init".to_owned(),
flag("confirm", true),
flag(
"gen-disablement-for-support",
params.gen_disablement_for_support,
),
format!("--gen-disablements={}", params.gen_disablements),
];
args.extend(trusted_keys.iter().cloned());
let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
let printed_text = output.stdout_str();
report(InitReport {
outcome: format!(
"tailnet lock is enabled for the tailnet, trusting {} key(s)",
trusted_keys.len()
),
trusted_keys,
disablement_secrets: tokens_with_prefix(&printed_text, &[DISABLEMENT_SECRET_PREFIX]),
gen_disablement_for_support: params.gen_disablement_for_support,
printed: printed(ctx, &output),
})
}
async fn lock_add(ctx: &ToolContext, params: LockKeysParams) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_add;
let keys = public_keys(¶ms.keys, "keys")?;
let mut args = vec!["lock".to_owned(), "add".to_owned()];
args.extend(keys.iter().cloned());
let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
report(LockReport {
outcome: "tailnet lock now trusts these keys to sign nodes and to change the lock"
.to_owned(),
keys,
printed: printed(ctx, &output),
})
}
async fn lock_remove(ctx: &ToolContext, params: LockRemoveParams) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_remove;
let keys = public_keys(¶ms.keys, "keys")?;
let mut args = vec!["lock".to_owned(), "remove".to_owned()];
push_bool(&mut args, "re-sign", params.re_sign);
args.extend(keys.iter().cloned());
let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
let outcome = if params.re_sign == Some(false) {
"tailnet lock no longer trusts these keys, and the nodes they signed are locked out"
} else {
"tailnet lock no longer trusts these keys; the nodes they signed were re-signed and stay admitted"
};
report(LockReport {
outcome: outcome.to_owned(),
keys,
printed: printed(ctx, &output),
})
}
async fn lock_sign(ctx: &ToolContext, params: LockSignParams) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_sign;
let key = params.key.trim();
if key.is_empty() {
return Err(ToolError::invalid_args("`key` cannot be empty"));
}
let (key_value, key_file) = secret_value("key", key)?;
let mut args = vec!["lock".to_owned(), "sign".to_owned(), key_value];
let rotation = params
.rotation_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let rotation_file = match rotation {
Some(rotation) => {
let (value, file) = secret_value("rotation_key", rotation)?;
args.push(value);
file
}
None => None,
};
let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
drop(key_file);
drop(rotation_file);
let signed_auth_key = tokens_with_prefix(&output.stdout_str(), &[AUTH_KEY_PREFIX])
.into_iter()
.next();
let (outcome, printed_text) = match &signed_auth_key {
Some(_) => (
"the auth key is signed and can now bring nodes up under tailnet lock",
note(ctx, &output.stderr),
),
None => (
"the node key is signed and the signature is with the control plane",
printed(ctx, &output),
),
};
report(SignReport {
outcome: outcome.to_owned(),
signed_auth_key,
printed: printed_text,
})
}
async fn lock_disable(ctx: &ToolContext, params: LockDisableParams) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_disable;
let secret = disablement_secret(ctx, "secret", ¶ms.secret)?;
if !secret.starts_with(DISABLEMENT_SECRET_PREFIX)
&& !secret.starts_with(DISABLEMENT_VALUE_PREFIX)
{
return Err(ToolError::invalid_args(format!(
"`secret` has to carry its `{DISABLEMENT_SECRET_PREFIX}` or `{DISABLEMENT_VALUE_PREFIX}` prefix, which says which half of the disablement it is"
)));
}
let output = cli::run(
ctx,
meta,
Invocation::mutate(["lock".to_owned(), "disable".to_owned(), secret]),
)
.await?;
report(StateReport {
outcome: "tailnet lock is off for the whole tailnet, and the secret that turned it off is now public".to_owned(),
printed: printed(ctx, &output),
})
}
async fn lock_disablement_kdf(
ctx: &ToolContext,
params: LockDisablementKdfParams,
) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_disablement_kdf;
let secret = disablement_secret(ctx, "secret", ¶ms.secret)?;
let hex = secret
.strip_prefix(DISABLEMENT_SECRET_PREFIX)
.unwrap_or(&secret);
if hex.is_empty() || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(ToolError::invalid_args(
"`secret` has to be a hex-encoded disablement secret, with or without its `disablement-secret:` prefix",
)
.with_hint(
"A `disablement:` value is the public half and is what this computes, not what it takes.",
));
}
let output = cli::run(
ctx,
meta,
Invocation::read([
"lock".to_owned(),
"disablement-kdf".to_owned(),
hex.to_owned(),
]),
)
.await?;
let text = output.stdout_str();
let disablement_value = tokens_with_prefix(&text, &[DISABLEMENT_VALUE_PREFIX])
.into_iter()
.next()
.unwrap_or_else(|| text.trim().to_owned());
report(DisablementReport {
disablement_value,
note: note(ctx, &output.stderr),
})
}
async fn lock_local_disable(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_local_disable;
let output = cli::run(ctx, meta, Invocation::mutate(["lock", "local-disable"])).await?;
report(StateReport {
outcome: "this node now accepts traffic from nodes tailnet lock has locked out; the tailnet's lock is untouched".to_owned(),
printed: printed(ctx, &output),
})
}
async fn lock_revoke_keys(ctx: &ToolContext, params: LockRevokeKeysParams) -> ToolResult<Value> {
let meta = &metas::tailscale_lock_revoke_keys;
if params.cosign && params.finish {
return Err(ToolError::invalid_args(
"`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",
));
}
let mut args = vec!["lock".to_owned(), "revoke-keys".to_owned()];
if params.cosign {
args.push(flag("cosign", true));
}
if params.finish {
args.push(flag("finish", true));
}
push_text(&mut args, "fork-from", params.fork_from.as_deref());
let blob = params
.recovery_blob
.as_deref()
.map(str::trim)
.filter(|blob| !blob.is_empty());
let keys = match blob {
Some(blob) => {
if !params.keys.is_empty() {
return Err(ToolError::invalid_args(
"name `keys` to start a revocation or `recovery_blob` to continue one, not both",
));
}
args.push(blob.to_owned());
Vec::new()
}
None => {
if params.cosign || params.finish {
return Err(ToolError::invalid_args(
"`cosign` and `finish` continue a revocation, so they need the `recovery_blob` the step before printed",
));
}
let keys = public_keys(¶ms.keys, "keys")?;
args.extend(keys.iter().cloned());
keys
}
};
let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
let outcome = if params.finish {
"the keys are revoked: every node they signed has lost its authorisation and has to be signed again"
} else if params.cosign {
"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"
} else {
"the revocation has begun; run the printed command on the next signing node to co-sign it"
};
report(LockReport {
outcome: outcome.to_owned(),
keys,
printed: printed(ctx, &output),
})
}
#[cfg(test)]
mod tests {
use std::io::Write;
use std::sync::Arc;
use super::*;
use crate::testing::{Reply, StubBackend, context};
const TLPUB: &str = "tlpub:0000000000000000000000000000000000000000000000000000000000000000";
const OTHER: &str = "tlpub:1111111111111111111111111111111111111111111111111111111111111111";
const NODEKEY: &str =
"nodekey:0000000000000000000000000000000000000000000000000000000000000000";
const HEX: &str = "00112233445566778899aabbccddeeff";
async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
where
F: FnOnce(ToolContext, P) -> Fut,
Fut: Future<Output = ToolResult<Value>>,
{
let backend = Arc::new(StubBackend::always(reply));
let ctx = context(Arc::clone(&backend));
let value = handler(ctx, params).await.expect("the handler succeeds");
(value, backend.argv())
}
async fn refused<F, P, Fut>(handler: F, params: P) -> ToolError
where
F: FnOnce(ToolContext, P) -> Fut,
Fut: Future<Output = ToolResult<Value>>,
{
let backend = Arc::new(StubBackend::always(Reply::ok("")));
let ctx = context(Arc::clone(&backend));
let error = handler(ctx, params).await.expect_err("the handler refuses");
assert!(
backend.argv().is_empty(),
"nothing should have run: {:?}",
backend.argv()
);
error
}
fn only(argv: &[Vec<String>]) -> &[String] {
assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
&argv[0]
}
#[tokio::test]
async fn initialising_always_confirms_and_states_every_flag() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_init(&ctx, p).await },
LockInitParams {
trusted_keys: vec![TLPUB.to_owned(), OTHER.to_owned()],
gen_disablements: 3,
gen_disablement_for_support: true,
},
)
.await;
assert_eq!(
only(&argv),
[
"lock",
"init",
"--confirm=true",
"--gen-disablement-for-support=true",
"--gen-disablements=3",
TLPUB,
OTHER,
]
);
}
#[tokio::test]
async fn initialising_reads_the_minted_secrets_out_of_what_was_printed() {
let printed = format!(
"Tailnet lock is now enabled.\n\
Disablement secrets:\n disablement-secret:{HEX}\n disablement-secret:ffee\n"
);
let (answer, _) = against(
Reply::ok(printed),
|ctx, p| async move { lock_init(&ctx, p).await },
LockInitParams {
trusted_keys: vec![TLPUB.to_owned()],
gen_disablements: 1,
gen_disablement_for_support: false,
},
)
.await;
assert_eq!(
answer["disablement_secrets"],
serde_json::json!([
format!("disablement-secret:{HEX}"),
"disablement-secret:ffee"
])
);
assert!(
answer["printed"]
.as_str()
.is_some_and(|text| text.contains(HEX)),
"the client's own text is kept as well: {answer}"
);
}
#[tokio::test]
async fn a_lock_with_no_way_back_is_refused() {
let error = refused(
|ctx, p| async move { lock_init(&ctx, p).await },
LockInitParams {
trusted_keys: vec![TLPUB.to_owned()],
gen_disablements: 0,
gen_disablement_for_support: false,
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn adding_keys_puts_them_after_the_subcommand() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_add(&ctx, p).await },
LockKeysParams {
keys: vec![TLPUB.to_owned(), OTHER.to_owned()],
},
)
.await;
assert_eq!(only(&argv), ["lock", "add", TLPUB, OTHER]);
assert_eq!(answer["keys"], serde_json::json!([TLPUB, OTHER]));
}
#[tokio::test]
async fn a_key_of_the_wrong_kind_is_named_by_position_and_not_by_value() {
let error = refused(
|ctx, p| async move { lock_add(&ctx, p).await },
LockKeysParams {
keys: vec![
TLPUB.to_owned(),
"tskey-auth-example-secretvalue".to_owned(),
],
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
assert!(error.message.contains("value 2"), "{}", error.message);
assert!(
!error.message.contains("tskey-auth-example-secretvalue"),
"the rejected value is not repeated: {}",
error.message
);
}
#[tokio::test]
async fn no_keys_at_all_is_refused() {
let error = refused(
|ctx, p| async move { lock_add(&ctx, p).await },
LockKeysParams::default(),
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn removing_keys_mentions_re_signing_only_when_it_was_asked_about() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_remove(&ctx, p).await },
LockRemoveParams {
keys: vec![TLPUB.to_owned()],
re_sign: None,
},
)
.await;
assert_eq!(only(&argv), ["lock", "remove", TLPUB]);
assert!(
answer["outcome"]
.as_str()
.is_some_and(|text| text.contains("stay admitted")),
"{answer}"
);
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_remove(&ctx, p).await },
LockRemoveParams {
keys: vec![TLPUB.to_owned()],
re_sign: Some(false),
},
)
.await;
assert_eq!(only(&argv), ["lock", "remove", "--re-sign=false", TLPUB]);
assert!(
answer["outcome"]
.as_str()
.is_some_and(|text| text.contains("locked out")),
"{answer}"
);
}
#[tokio::test]
async fn a_key_given_directly_reaches_the_client_as_a_private_file() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_sign(&ctx, p).await },
LockSignParams {
key: NODEKEY.to_owned(),
rotation_key: None,
},
)
.await;
let argv = only(&argv);
assert_eq!(&argv[..2], ["lock", "sign"]);
assert!(
argv[2].starts_with("file:") && argv[2].ends_with(".key"),
"the key itself must not reach the argument list: {argv:?}"
);
}
#[tokio::test]
async fn a_file_reference_is_passed_through_as_it_stands() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_sign(&ctx, p).await },
LockSignParams {
key: "file:/var/keys/node.key".to_owned(),
rotation_key: Some(NODEKEY.to_owned()),
},
)
.await;
let argv = only(&argv);
assert_eq!(argv[2], "file:/var/keys/node.key");
assert!(
argv[3].starts_with("file:") && argv[3].ends_with(".key"),
"the rotation key gets a private file of its own: {argv:?}"
);
}
#[tokio::test]
async fn a_signed_auth_key_comes_back_whole_and_only_once() {
let signed = "tskey-auth-example0CNTRL-signedvalue";
let (answer, _) = against(
Reply::ok(format!("{signed}\n")),
|ctx, p| async move { lock_sign(&ctx, p).await },
LockSignParams {
key: "tskey-auth-example0CNTRL".to_owned(),
rotation_key: None,
},
)
.await;
assert_eq!(answer["signed_auth_key"], signed);
assert!(
answer["printed"].is_null(),
"the key is carried once, not twice: {answer}"
);
}
#[tokio::test]
async fn signing_a_node_key_reports_no_auth_key() {
let (answer, _) = against(
Reply::ok(""),
|ctx, p| async move { lock_sign(&ctx, p).await },
LockSignParams {
key: NODEKEY.to_owned(),
rotation_key: None,
},
)
.await;
assert!(answer["signed_auth_key"].is_null(), "{answer}");
assert!(
answer["outcome"]
.as_str()
.is_some_and(|text| text.contains("node key")),
"{answer}"
);
}
#[tokio::test]
async fn an_empty_key_is_refused() {
let error = refused(
|ctx, p| async move { lock_sign(&ctx, p).await },
LockSignParams {
key: " ".to_owned(),
rotation_key: None,
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn a_disablement_without_its_prefix_is_refused() {
let error = refused(
|ctx, p| async move { lock_disable(&ctx, p).await },
LockDisableParams {
secret: HEX.to_owned(),
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn a_disablement_secret_can_be_kept_in_a_file() {
let mut file = tempfile::NamedTempFile::new().expect("a temporary file");
writeln!(file, "disablement-secret:{HEX}").expect("the secret is written");
let path = file.path().display().to_string();
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_disable(&ctx, p).await },
LockDisableParams {
secret: format!("file:{path}"),
},
)
.await;
assert_eq!(
only(&argv),
["lock", "disable", &format!("disablement-secret:{HEX}")]
);
}
#[tokio::test]
async fn a_file_that_is_not_there_is_not_found() {
let error = refused(
|ctx, p| async move { lock_disable(&ctx, p).await },
LockDisableParams {
secret: "file:/nowhere/at/all/secret.txt".to_owned(),
},
)
.await;
assert_eq!(error.code, ErrorCode::NotFound);
}
#[tokio::test]
async fn the_kdf_takes_the_prefix_off_a_minted_secret() {
let (answer, argv) = against(
Reply::ok("disablement:756fe19f200fbfc9ad431e75c7942b82\n"),
|ctx, p| async move { lock_disablement_kdf(&ctx, p).await },
LockDisablementKdfParams {
secret: format!("disablement-secret:{HEX}"),
},
)
.await;
assert_eq!(only(&argv), ["lock", "disablement-kdf", HEX]);
assert_eq!(
answer["disablement_value"],
"disablement:756fe19f200fbfc9ad431e75c7942b82"
);
}
#[tokio::test]
async fn the_kdf_refuses_something_that_is_not_hex() {
let error = refused(
|ctx, p| async move { lock_disablement_kdf(&ctx, p).await },
LockDisablementKdfParams {
secret: "disablement:756fe1".to_owned(),
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn starting_a_revocation_names_the_keys() {
let (answer, argv) = against(
Reply::ok("next: tailscale lock revoke-keys --cosign blob\n"),
|ctx, p| async move { lock_revoke_keys(&ctx, p).await },
LockRevokeKeysParams {
keys: vec![TLPUB.to_owned()],
..LockRevokeKeysParams::default()
},
)
.await;
assert_eq!(only(&argv), ["lock", "revoke-keys", TLPUB]);
assert!(
answer["printed"]
.as_str()
.is_some_and(|text| text.contains("--cosign")),
"the command to run next is what the caller needs: {answer}"
);
}
#[tokio::test]
async fn continuing_a_revocation_puts_the_blob_after_its_flags() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { lock_revoke_keys(&ctx, p).await },
LockRevokeKeysParams {
recovery_blob: Some("abcdef".to_owned()),
cosign: true,
fork_from: Some("aum-hash".to_owned()),
..LockRevokeKeysParams::default()
},
)
.await;
assert_eq!(
only(&argv),
[
"lock",
"revoke-keys",
"--cosign=true",
"--fork-from=aum-hash",
"abcdef",
]
);
}
#[tokio::test]
async fn the_two_ways_of_continuing_are_not_both_at_once() {
let error = refused(
|ctx, p| async move { lock_revoke_keys(&ctx, p).await },
LockRevokeKeysParams {
recovery_blob: Some("abcdef".to_owned()),
cosign: true,
finish: true,
..LockRevokeKeysParams::default()
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn continuing_without_the_blob_from_the_step_before_is_refused() {
let error = refused(
|ctx, p| async move { lock_revoke_keys(&ctx, p).await },
LockRevokeKeysParams {
keys: vec![TLPUB.to_owned()],
finish: true,
..LockRevokeKeysParams::default()
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn keys_and_a_blob_together_are_refused() {
let error = refused(
|ctx, p| async move { lock_revoke_keys(&ctx, p).await },
LockRevokeKeysParams {
keys: vec![TLPUB.to_owned()],
recovery_blob: Some("abcdef".to_owned()),
..LockRevokeKeysParams::default()
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
}