Skip to main content

tailscale_mcp/tools/
local_lock.rs

1//! The tools that change the tailnet's trust root.
2//!
3//! Tailnet lock decides which nodes the tailnet will accept, independently of
4//! the control plane: a node is admitted only once a key the tailnet
5//! already trusts has signed it. These eight tools act through *this* node's
6//! own tailnet-lock key, so what they can do is what this node is trusted to
7//! do. Reading the state is elsewhere — `tailscale_lock_status` and
8//! `tailscale_lock_log` sit with the other read-only status tools.
9//!
10//! Three of them are irreversible for the whole tailnet rather than for this
11//! node, so they carry a confirmation on top of the destructive tier:
12//! initialising the lock, disabling it with a disablement secret, and revoking
13//! keys. `tailscale_lock_local_disable` is destructive without one, because it
14//! changes only what this node will accept and leaves the tailnet's lock
15//! standing.
16//!
17//! Two kinds of secret pass through here, and they are handled differently
18//! because the client handles them differently.
19//!
20//! A **node or auth key** being signed may be given as `file:<path>`, which the
21//! client reads itself, so `tailscale_lock_sign` never has to see it: a literal
22//! is written to a private temporary file and the path is what runs. A
23//! **disablement secret** has no such form — `lock disable` and `lock
24//! disablement-kdf` take it as a positional argument and nothing else — so this
25//! module honours `file:<path>` on their behalf by reading the file itself.
26//! That keeps the secret out of the conversation, which is the exposure a
27//! caller controls; it cannot keep it off the argument list, which is the one
28//! the client's own interface fixes (DECISIONS Q38).
29//!
30//! `tailscale_lock_init` always passes `--confirm=true`. Without it the client
31//! prints its warning, reads end-of-file from a standard input that a tool call
32//! does not have, and exits successfully having done nothing at all — a silent
33//! no-op reported as success, which is the worst answer available. The
34//! confirmation the caller gave this server is the same question, asked where
35//! there is someone to answer it (DECISIONS Q39).
36//!
37//! Both `tailscale_lock_init` and `tailscale_lock_sign` can mint a secret, and
38//! a minted secret comes back whole in the answer to the call that minted it:
39//! it is the product of the call, and redacting it would leave the caller with
40//! nothing. Nothing here stores one, nothing here logs one, and no later call
41//! can produce one again.
42//!
43//! Whether that answer also keeps the text the secret was read out of differs
44//! between the two, because what a lost secret costs differs between the two
45//! (DECISIONS Q43). `tailscale_lock_sign` withholds it: if the parse missed the
46//! auth key, sign again. `tailscale_lock_init` keeps it, so that a parse
47//! written against today's output is never the only copy of the disablement
48//! secrets — they cannot be minted twice, and without one the lock can never be
49//! turned off.
50
51use 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    /// Turn on tailnet lock for the whole tailnet, trusting the tailnet-lock
65    /// keys given here to sign nodes and to make further lock changes. Mints
66    /// the disablement secrets that are the only way back, and returns them
67    /// once: store them before answering, because nothing here keeps a copy.
68    /// Affects every node in the tailnet, so it needs a confirmation as well
69    /// as the destructive tier.
70    tailscale_lock_init => LockInitParams, lock_init,
71        toolset: LocalLock, tier: Destructive, confirm: true;
72
73    /// Trust more tailnet-lock keys, so that the nodes holding them can sign
74    /// nodes and change tailnet lock. Adding a key that is already trusted
75    /// changes nothing.
76    tailscale_lock_add => LockKeysParams, lock_add,
77        toolset: LocalLock, tier: Write, idempotent: true;
78
79    /// Stop trusting tailnet-lock keys. Signatures those keys made are re-signed
80    /// by default so that the nodes they admitted stay admitted; turning that
81    /// off locks those nodes out. Use `tailscale_lock_revoke_keys` instead if a
82    /// key was compromised.
83    tailscale_lock_remove => LockRemoveParams, lock_remove,
84        toolset: LocalLock, tier: Destructive;
85
86    /// Sign a node key, admitting that node to a tailnet under lock, or sign a
87    /// pre-approved auth key so that it can bring nodes up. Give the key
88    /// directly or as `file:<path>` to a file holding it. A signed auth key is
89    /// returned once and is not stored.
90    tailscale_lock_sign => LockSignParams, lock_sign,
91        toolset: LocalLock, tier: Write;
92
93    /// Turn tailnet lock off for the whole tailnet by spending one of the
94    /// disablement secrets minted when it was initialised. The secret is
95    /// consumed and becomes public; re-enabling the lock means initialising it
96    /// again from scratch. Needs a confirmation as well as the destructive
97    /// tier.
98    tailscale_lock_disable => LockDisableParams, lock_disable,
99        toolset: LocalLock, tier: Destructive, confirm: true;
100
101    /// Compute the public disablement value that corresponds to a disablement
102    /// secret, without disabling anything and without contacting anything.
103    /// Local arithmetic on the value given, for checking a stored secret
104    /// against what `tailscale_lock_status` reports.
105    tailscale_lock_disablement_kdf => LockDisablementKdfParams, lock_disablement_kdf,
106        toolset: LocalLock, tier: Read, idempotent: true;
107
108    /// Make this node accept traffic from nodes that tailnet lock has locked
109    /// out. Affects this node only: the tailnet's lock stays on and every other
110    /// node keeps enforcing it.
111    tailscale_lock_local_disable => NoParams, lock_local_disable,
112        toolset: LocalLock, tier: Destructive;
113
114    /// Retroactively revoke compromised tailnet-lock keys, so that every node
115    /// they signed loses its authorisation and must be signed again. Several
116    /// signing nodes have to co-sign: start with `keys`, then re-run with the
117    /// `recovery_blob` the previous step printed and `cosign` on each further
118    /// node, then once with `finish`. Needs a confirmation as well as the
119    /// destructive tier.
120    tailscale_lock_revoke_keys => LockRevokeKeysParams, lock_revoke_keys,
121        toolset: LocalLock, tier: Destructive, confirm: true;
122}
123
124// ---------------------------------------------------------------------------
125// Shapes the client insists on
126// ---------------------------------------------------------------------------
127
128/// What every tailnet-lock key starts with. Public by design: a `tlpub:` key
129/// identifies a signing node and is meant to be copied between them, so unlike
130/// everything else in this module it goes on the argument list as it stands and
131/// is echoed back in the answer.
132const KEY_PREFIX: &str = "tlpub:";
133
134/// What an auth key starts with, which is how the answer to a `lock sign` tells
135/// a signed auth key from a signed node key without being told which was asked
136/// for — the caller may have named a file rather than a key.
137const AUTH_KEY_PREFIX: &str = "tskey-";
138
139/// The private half of a disablement, as `lock init` mints it.
140const DISABLEMENT_SECRET_PREFIX: &str = "disablement-secret:";
141
142/// The public half, as `lock disablement-kdf` computes it and `lock status`
143/// reports it.
144const DISABLEMENT_VALUE_PREFIX: &str = "disablement:";
145
146/// A tool that takes nothing.
147#[derive(Debug, Deserialize, JsonSchema)]
148pub struct NoParams {}
149
150// ---------------------------------------------------------------------------
151// Parameters
152// ---------------------------------------------------------------------------
153
154const fn default_gen_disablements() -> u32 {
155    1
156}
157
158#[derive(Debug, Deserialize, JsonSchema)]
159pub struct LockInitParams {
160    /// The tailnet-lock keys to trust initially, each beginning with `tlpub:`.
161    /// This node's own key, which `tailscale_lock_status` reports, has to be
162    /// among them or the client refuses. At least one.
163    pub trusted_keys: Vec<String>,
164    /// How many disablement secrets to mint. At least one — the client refuses
165    /// a lock with no way back — and they are returned only by this call.
166    #[serde(default = "default_gen_disablements")]
167    pub gen_disablements: u32,
168    /// Also mint a disablement secret and send it to Tailscale, so that
169    /// Tailscale support can disable the lock. Recommended by the client, and
170    /// off unless asked for, because it hands a third party the way back.
171    #[serde(default)]
172    pub gen_disablement_for_support: bool,
173}
174
175#[derive(Debug, Default, Deserialize, JsonSchema)]
176pub struct LockKeysParams {
177    /// The tailnet-lock keys to trust, each beginning with `tlpub:`.
178    pub keys: Vec<String>,
179}
180
181#[derive(Debug, Default, Deserialize, JsonSchema)]
182pub struct LockRemoveParams {
183    /// The tailnet-lock keys to stop trusting, each beginning with `tlpub:`.
184    pub keys: Vec<String>,
185    /// Re-sign the signatures that removing these keys would invalidate, so
186    /// that the nodes they admitted stay admitted. The client's own default is
187    /// to re-sign; setting this to `false` locks those nodes out.
188    #[serde(default)]
189    pub re_sign: Option<bool>,
190}
191
192#[derive(Debug, Default, Deserialize, JsonSchema)]
193pub struct LockSignParams {
194    /// The node key to sign, or the pre-approved auth key to sign. Either the
195    /// key itself or `file:<path>` to a file holding it; a key given directly
196    /// is written to a private file before the client is run, so it never
197    /// reaches an argument list.
198    pub key: String,
199    /// The rotation key to attach to a node signature, in the same two forms.
200    /// Only meaningful when signing a node key.
201    #[serde(default)]
202    pub rotation_key: Option<String>,
203}
204
205#[derive(Debug, Default, Deserialize, JsonSchema)]
206pub struct LockDisableParams {
207    /// One of the disablement secrets minted when tailnet lock was
208    /// initialised, beginning with `disablement-secret:` or `disablement:`.
209    /// May instead be `file:<path>` to a file holding it.
210    pub secret: String,
211}
212
213#[derive(Debug, Default, Deserialize, JsonSchema)]
214pub struct LockDisablementKdfParams {
215    /// A disablement secret, as hex or with its `disablement-secret:` prefix,
216    /// or `file:<path>` to a file holding it.
217    pub secret: String,
218}
219
220#[derive(Debug, Default, Deserialize, JsonSchema)]
221pub struct LockRevokeKeysParams {
222    /// The compromised tailnet-lock keys to revoke, each beginning with
223    /// `tlpub:`. Names the keys only on the call that starts a revocation.
224    #[serde(default)]
225    pub keys: Vec<String>,
226    /// The recovery blob the previous step printed, which continues a
227    /// revocation already begun. Give this instead of `keys`.
228    #[serde(default)]
229    pub recovery_blob: Option<String>,
230    /// Co-sign the recovery with this node's tailnet-lock key. Run once on each
231    /// further signing node, each time with the blob the last step printed.
232    #[serde(default)]
233    pub cosign: bool,
234    /// Transmit the revocation, ending the process. Valid once there are more
235    /// co-signatures than keys being revoked.
236    #[serde(default)]
237    pub finish: bool,
238    /// The parent AUM hash to rewrite from. Advanced; leave it out unless the
239    /// recovery instructions said otherwise.
240    #[serde(default)]
241    pub fork_from: Option<String>,
242}
243
244// ---------------------------------------------------------------------------
245// Reports
246// ---------------------------------------------------------------------------
247
248/// What a lock change did to a named set of keys.
249#[derive(Debug, Serialize, JsonSchema)]
250pub struct LockReport {
251    /// What is true now that was not true before.
252    pub outcome: String,
253    /// The tailnet-lock keys the call named, echoed so that the answer stands
254    /// on its own. Public keys only; nothing secret is ever echoed here. Absent
255    /// on a revocation continued from a blob, which names no keys.
256    #[serde(skip_serializing_if = "Vec::is_empty")]
257    pub keys: Vec<String>,
258    /// Whatever the client printed, which for a multi-step revocation is the
259    /// command to run next.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub printed: Option<String>,
262}
263
264/// What a lock change did, where there is no key to name: the subject is the
265/// lock itself, on the tailnet or on this node.
266#[derive(Debug, Serialize, JsonSchema)]
267pub struct StateReport {
268    /// What is true now that was not true before.
269    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    /// The keys tailnet lock now trusts.
278    pub trusted_keys: Vec<String>,
279    /// The disablement secrets this call minted, in the order the client
280    /// printed them. This server does not store them and no later call can
281    /// produce them again, so this answer is the only time they are offered.
282    /// Losing them means the lock can never be disabled.
283    #[serde(skip_serializing_if = "Vec::is_empty")]
284    pub disablement_secrets: Vec<String>,
285    /// Whether a further secret was also sent to Tailscale support.
286    pub gen_disablement_for_support: bool,
287    /// The client's own text, kept alongside the secrets read out of it so that
288    /// a parse written against today's output is never the only copy of
289    /// something that cannot be minted twice (DECISIONS Q43).
290    #[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    /// The signed auth key, present only when an auth key was what got signed.
298    /// It is the product of the call, so it is returned whole, and the text it
299    /// was read out of is withheld rather than repeated (DECISIONS Q43).
300    #[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    /// The public disablement value, as `disablement:<hex>`. Safe to compare
309    /// against what `tailscale_lock_status` reports; the secret it came from is
310    /// not repeated.
311    pub disablement_value: String,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub note: Option<String>,
314}
315
316// ---------------------------------------------------------------------------
317// Argument helpers
318// ---------------------------------------------------------------------------
319
320/// Check a list of tailnet-lock keys and hand back the trimmed spellings.
321///
322/// A rejected key is reported by its position rather than its value: the value
323/// is the caller's to see and ours not to repeat, and a caller that mistook an
324/// auth key for a lock key would otherwise have pasted a secret into an error
325/// message.
326fn 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
347/// A disablement secret, read out of a file when the caller pointed at one.
348///
349/// Neither command that takes one has a `file:` form of its own, so this server
350/// honours one on their behalf. What that buys is real but partial: the secret
351/// need not be pasted into the conversation, though it still reaches the
352/// argument list the client insists on (DECISIONS Q38).
353///
354/// The mirror image of [`secret_value`], which writes a secret *out* to a
355/// private file so that a literal never reaches an argument list.
356fn 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
385// ---------------------------------------------------------------------------
386// Handlers
387// ---------------------------------------------------------------------------
388
389async fn lock_init(ctx: &ToolContext, params: LockInitParams) -> ToolResult<Value> {
390    let meta = &metas::tailscale_lock_init;
391    let trusted_keys = public_keys(&params.trusted_keys, "trusted_keys")?;
392    // The client's own rule, applied here so that it costs a message rather
393    // than a spawn. There is deliberately no ceiling: how many places a caller
394    // has to keep a secret safe is theirs to know, not this server's to guess.
395    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        // Never omitted: see the module note on the silent no-op.
405        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(&params.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(&params.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    // Which of the two things the client did is read off what it printed rather
491    // than off what was asked for: a caller that named a file did not tell us
492    // which kind of key was in it.
493    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        // Standard output holds the signed key, which the field above already
498        // carries whole; repeating it redacted would only be confusing, so only
499        // the client's commentary is passed on.
500        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", &params.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", &params.secret)?;
545    // This command wants bare hex while `lock disable` insists on a prefix and
546    // `lock init` mints the prefixed form, so a caller holding a minted secret
547    // has the wrong shape for it. Taking the prefix off here rather than making
548    // that the caller's problem is the whole of the difference.
549    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(&params.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    /// Documentation values throughout: a real `tlpub:` key names a real
660    /// signing node, and none of this tailnet's belong in a repository.
661    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    /// Run a handler against a scripted client and report both what it answered
668    /// and what it ran.
669    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    /// The same for a call that should be refused, which is only interesting
681    /// alongside the fact that nothing was run.
682    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    /// The argument list of the single command a handler ran.
699    fn only(argv: &[Vec<String>]) -> &[String] {
700        assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
701        &argv[0]
702    }
703
704    // -- init ---------------------------------------------------------------
705
706    /// The confirmation is not optional: without it the client reads end-of-file
707    /// from a standard input a tool call does not have and exits successfully
708    /// having initialised nothing.
709    #[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    /// The minted secrets are the whole point of the call, so they are read out
737    /// of what the client printed *and* left in it: they cannot be minted a
738    /// second time, so a parse written against today's output must not be the
739    /// only copy (DECISIONS Q43).
740    #[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    // -- add and remove -----------------------------------------------------
787
788    #[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    /// A key that is not a lock key is reported by position, so that a caller
804    /// who pasted a secret into the wrong parameter does not get it back in an
805    /// error message.
806    #[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    /// Re-signing is the client's default, so an unspecified call says nothing
839    /// about it rather than restating it.
840    #[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    // -- sign ---------------------------------------------------------------
878
879    #[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    /// A caller that already has the key in a file is handing over a path, and
900    /// copying it somewhere else would gain nothing.
901    #[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    /// A signed auth key is what the call was for, so it comes back whole. The
922    /// shape-based redaction would remove exactly the answer.
923    #[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    // -- disable and the KDF ------------------------------------------------
978
979    /// The prefix says which half of the disablement a value is, and the client
980    /// refuses a bare one. Refusing it here keeps it off the argument list.
981    #[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    /// `lock disable` has no `file:` form of its own, so this server reads the
994    /// file. The secret still reaches the argument list, which is the client's
995    /// interface and not ours to change (DECISIONS Q38).
996    #[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    /// The two commands want the same secret in two shapes. Which one the caller
1030    /// happens to hold is not a thing to make it think about.
1031    #[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    // -- revoke-keys --------------------------------------------------------
1062
1063    #[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}