ts_keys/keystate.rs
1use core::fmt::{Debug, Display, Formatter};
2
3use crate::{
4 DiscoKeyPair, MachineKeyPair, MachinePrivateKey, NetworkLockKeyPair, NetworkLockPrivateKey,
5 NodeKeyPair, NodePrivateKey, NodePublicKey,
6};
7
8/// The portion of the key state that should be retained between runs of the same device.
9///
10/// Disco keys are ephemeral and should be generated anew each time a device runs, so are
11/// excluded from this state.
12///
13/// # At-rest protection is the embedder's responsibility
14///
15/// The secret-bearing fields here are zeroized in memory on drop (the dedicated key types and the
16/// [`Zeroizing`](zeroize::Zeroizing)-wrapped ACME account key), but that is an in-process hygiene
17/// measure only. Protecting this state **at rest** — restrictive file permissions (e.g. `0o600`),
18/// full-disk or filesystem encryption, secure-enclave/keyring storage — is entirely the
19/// responsibility of the embedding application that serializes and writes it to durable storage.
20/// This crate neither reads nor writes files and makes no at-rest guarantee (see `SECURITY.md`).
21#[derive(Clone, Debug)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct PersistState {
24 /// The [`MachinePrivateKey`] for the hardware this Tailnet peer runs on.
25 pub machine_key: MachinePrivateKey,
26
27 /// The [`NetworkLockPrivateKey`] for this Tailnet peer, for use with Tailnet Lock.
28 pub network_lock_key: NetworkLockPrivateKey,
29
30 /// The [`NodePrivateKey`] for this Tailnet peer.
31 pub node_key: NodePrivateKey,
32
33 /// The node's PREVIOUS node public key, recorded during a node-key rotation so the next
34 /// registration sends it as `RegisterRequest.OldNodeKey` for key continuity (Go's `regen` flow).
35 /// `None` outside a rotation (the default). Reactive / embedder-driven — matching Go, this fork
36 /// does NOT auto-rotate the node key before expiry (Go deliberately doesn't either; key expiry is
37 /// a human-re-auth control). See [`PersistState::rotate_node_key`].
38 #[cfg_attr(feature = "serde", serde(default))]
39 pub old_node_key: Option<NodePublicKey>,
40
41 /// The persisted ACME account key (PKCS#8 DER of an ECDSA P-256 key), or `None` if no ACME
42 /// account has been provisioned for this node. The `acme` cert-issuance path loads this to keep
43 /// the same Let's Encrypt account identity across renewals; absent, the runtime generates an
44 /// ephemeral per-call key (a new ACME account each issuance). `#[serde(default)]` so key files
45 /// written before this field load as `None` (mirrors [`old_node_key`](PersistState::old_node_key)).
46 ///
47 /// Wrapped in [`Zeroizing`](zeroize::Zeroizing) so the DER private-key bytes are wiped from
48 /// memory on drop. `Zeroizing<Vec<u8>>` serializes transparently via its inner `Vec`, so the
49 /// persisted JSON shape is identical to a bare `Vec<u8>` (a byte array).
50 #[cfg_attr(feature = "serde", serde(default))]
51 pub acme_account_key: Option<zeroize::Zeroizing<alloc::vec::Vec<u8>>>,
52}
53
54impl PersistState {
55 /// Rotate the node key for re-registration, mirroring Go's `regen` flow: record the current
56 /// node public key as [`old_node_key`](PersistState::old_node_key) and replace the node key with
57 /// a freshly-generated one. The next registration that uses this state will send the prior key
58 /// as `RegisterRequest.OldNodeKey`, so control links the new node key to the node's existing
59 /// identity instead of treating it as a brand-new node.
60 ///
61 /// This is the embedder-driven rotation primitive (re-create the device with the returned state).
62 /// It is reactive, NOT a pre-expiry auto-rotator: Go has no such timer, because node-key expiry
63 /// is a deliberate periodic human/IdP re-attestation control. Re-registration still requires a
64 /// valid auth credential, exactly as a fresh registration does.
65 ///
66 // TODO(TKA): on a tailnet-lock-enabled tailnet, a node-key rotation must also re-sign the node
67 // key with the network-lock key and send the new `RegisterRequest.NodeKeySignature`. This
68 // primitive covers the non-TKA path; TKA re-sign is a separate follow-up.
69 pub fn rotate_node_key(&mut self) {
70 self.old_node_key = Some(self.node_key.public_key());
71 self.node_key = NodePrivateKey::random();
72 }
73}
74
75impl From<&NodeState> for PersistState {
76 fn from(value: &NodeState) -> Self {
77 Self {
78 // `.clone()` (not a bare field read): the private keys are no longer `Copy`, so copying
79 // them out of a `&NodeState` is now an explicit clone. The original keys stay owned by
80 // `value` and zeroize on their own drop.
81 node_key: value.node_keys.private.clone(),
82 machine_key: value.machine_keys.private.clone(),
83 network_lock_key: value.network_lock_keys.private.clone(),
84 old_node_key: value.old_node_key,
85 acme_account_key: value.acme_account_key.clone(),
86 }
87 }
88}
89
90impl From<NodeState> for PersistState {
91 fn from(value: NodeState) -> Self {
92 Self::from(&value)
93 }
94}
95
96impl Default for PersistState {
97 fn default() -> Self {
98 Self {
99 machine_key: MachinePrivateKey::random(),
100 network_lock_key: NetworkLockPrivateKey::random(),
101 node_key: NodePrivateKey::random(),
102 old_node_key: None,
103 acme_account_key: None,
104 }
105 }
106}
107
108/// The complete runtime key state for a Tailscale node.
109#[derive(Clone, Default)]
110#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
111pub struct NodeState {
112 /// The [`DiscoKeyPair`] this Tailnet peer uses for the Disco protocol.
113 ///
114 /// These should be randomly generated for each run of a Tailscale device.
115 pub disco_keys: DiscoKeyPair,
116
117 /// The [`MachineKeyPair`] for the hardware this Tailnet peer runs on.
118 pub machine_keys: MachineKeyPair,
119
120 /// The [`NetworkLockKeyPair`] for this Tailnet peer, for use with Tailnet Lock.
121 pub network_lock_keys: NetworkLockKeyPair,
122
123 /// The [`NodeKeyPair`] for this Tailnet peer.
124 pub node_keys: NodeKeyPair,
125
126 /// The node's previous node public key during a rotation (see
127 /// [`PersistState::old_node_key`]). Threaded to registration as `RegisterRequest.OldNodeKey`.
128 #[cfg_attr(feature = "serde", serde(default))]
129 pub old_node_key: Option<NodePublicKey>,
130
131 /// The persisted ACME account key (PKCS#8 DER), threaded from
132 /// [`PersistState::acme_account_key`]. The `acme` cert-issuance path reads this to reuse the
133 /// same Let's Encrypt account across renewals. `None` when no ACME account is provisioned.
134 ///
135 /// Wrapped in [`Zeroizing`](zeroize::Zeroizing) so the DER private-key bytes are wiped from
136 /// memory on drop; serializes transparently via the inner `Vec` (unchanged JSON shape).
137 #[cfg_attr(feature = "serde", serde(default))]
138 pub acme_account_key: Option<zeroize::Zeroizing<alloc::vec::Vec<u8>>>,
139}
140
141impl Debug for NodeState {
142 fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
143 f.debug_tuple("NodeState")
144 .field(&self.machine_keys.public)
145 .field(&self.node_keys.public)
146 .field(&self.disco_keys.public)
147 .field(&self.network_lock_keys.public)
148 .finish()
149 }
150}
151
152impl Display for NodeState {
153 fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
154 Debug::fmt(self, f)
155 }
156}
157
158impl NodeState {
159 /// Generate a new [`NodeState`]. All keys get random values.
160 pub fn generate() -> Self {
161 Default::default()
162 }
163
164 /// Rotate the node key for re-registration, the runtime twin of
165 /// [`PersistState::rotate_node_key`]: record the current node public key as
166 /// [`old_node_key`](NodeState::old_node_key) and replace the [`node_keys`](NodeState::node_keys)
167 /// pair with a freshly-generated one. The next registration built from this state sends the prior
168 /// key as `RegisterRequest.OldNodeKey`, so control links the new node key to the node's existing
169 /// identity instead of treating it as a brand-new node (Go's `regen`/`doLogin` flow).
170 ///
171 /// Only the **node key** rotates. The disco and machine keys are deliberately left untouched: the
172 /// data plane (magicsock/WireGuard sessions, disco) keys on those and on per-peer keys, never on
173 /// the self node key, so a node-key rotation does not re-key or flap an established tunnel. The
174 /// node key is a control-plane identity. (The disco ping packet does carry the self node key as a
175 /// claimed-sender identity — a caller that rotates at runtime should refresh magicsock's copy so
176 /// outbound pings advertise the new key, but that is a magicsock concern, not a key-state one.)
177 ///
178 /// **`old_node_key` anchor lifecycle.** This records the prior key as `old_node_key` **only if no
179 /// rotation anchor is already held** ([`old_node_key`](NodeState::old_node_key) is currently
180 /// `None`). A second rotation *before* a successful re-register therefore preserves the
181 /// **original** pre-expiry key as the anchor, rather than overwriting it with the intermediate
182 /// rotated key — control links a rotation against the key it already knows, so the anchor must
183 /// stay pinned to the last *registered* key, not a transient one. The anchor is released back to
184 /// `None` by [`clear_old_node_key`](NodeState::clear_old_node_key), which the re-register path
185 /// calls on a **successful** register: once control has accepted the rotated key, that key is now
186 /// the node's known identity, so a subsequent genuine expiry cycle correctly captures it as the
187 /// new anchor. (A runtime caller that drives repeated rotations is additionally expected not to
188 /// rotate again while a re-register is unconfirmed; this guard is the belt-and-suspenders.)
189 ///
190 // TODO(TKA): on a tailnet-lock-enabled tailnet, a node-key rotation must also re-sign the new node
191 // key with the network-lock key and send the new `RegisterRequest.NodeKeySignature`. This
192 // primitive covers the non-TKA path; the TKA re-sign is a separate follow-up, so an auto-reauth
193 // caller must gate rotation OFF while lock enforcement is active (else the node locks itself out
194 // of locked peers with an unsigned key).
195 pub fn rotate_node_key(&mut self) {
196 // Preserve an existing anchor: only capture the current key as `old_node_key` when none is
197 // held, so two rotations before a successful register keep the ORIGINAL pre-expiry key as the
198 // linkage anchor control expects (see the lifecycle note above).
199 if self.old_node_key.is_none() {
200 self.old_node_key = Some(self.node_keys.public);
201 }
202 self.node_keys = NodeKeyPair::new();
203 }
204
205 /// Release the rotation anchor: drop any [`old_node_key`](NodeState::old_node_key) back to `None`.
206 ///
207 /// Called by the re-register path on a **successful** register. After control accepts a rotated
208 /// node key, that key is the node's known identity, so the next genuine node-key rotation should
209 /// anchor on *it* (capture it fresh as `old_node_key`) rather than re-sending a now-stale prior
210 /// key. Paired with [`rotate_node_key`](NodeState::rotate_node_key)'s preserve-if-`Some` guard:
211 /// the anchor is captured once per rotation episode and cleared once the episode is confirmed.
212 /// A no-op when no anchor is held (the steady-state case — every successful map-poll re-register
213 /// calls this and it harmlessly does nothing while `old_node_key` is already `None`).
214 pub fn clear_old_node_key(&mut self) {
215 self.old_node_key = None;
216 }
217}
218
219impl From<&PersistState> for NodeState {
220 fn from(value: &PersistState) -> Self {
221 Self {
222 disco_keys: Default::default(),
223 // `.clone().into()`: building each keypair consumes a private key, which can no longer
224 // be `Copy`-d out of the `&PersistState`. Clone the stored key, then derive the pair
225 // (the pair's public half is computed from a borrow inside `From<$private>`).
226 node_keys: value.node_key.clone().into(),
227 machine_keys: value.machine_key.clone().into(),
228 network_lock_keys: value.network_lock_key.clone().into(),
229 old_node_key: value.old_node_key,
230 acme_account_key: value.acme_account_key.clone(),
231 }
232 }
233}
234
235impl From<PersistState> for NodeState {
236 fn from(value: PersistState) -> Self {
237 Self::from(&value)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn rotate_node_key_sets_old_and_fresh() {
247 let mut state = PersistState::default();
248 let before_pub = state.node_key.public_key();
249
250 state.rotate_node_key();
251
252 assert_eq!(state.old_node_key, Some(before_pub));
253 assert_ne!(state.node_key.public_key(), before_pub);
254 }
255
256 #[test]
257 fn node_state_rotate_node_key_sets_old_and_fresh() {
258 let mut state = NodeState::generate();
259 let before_pub = state.node_keys.public;
260 // The other key roles must be preserved across a node-key rotation (only the node key
261 // rotates — disco/machine/network-lock are unchanged, which is what keeps tunnels from
262 // flapping and keeps a locked-tailnet re-sign possible).
263 let disco_before = state.disco_keys.public;
264 let machine_before = state.machine_keys.public;
265 let lock_before = state.network_lock_keys.public;
266
267 state.rotate_node_key();
268
269 // Old key recorded, node key replaced with a fresh one.
270 assert_eq!(state.old_node_key, Some(before_pub));
271 assert_ne!(state.node_keys.public, before_pub);
272 // The fresh keypair's public matches its private (it's a real, consistent pair).
273 assert_eq!(
274 state.node_keys.public,
275 NodePublicKey::from(&state.node_keys.private)
276 );
277 // Every other key role is untouched.
278 assert_eq!(state.disco_keys.public, disco_before);
279 assert_eq!(state.machine_keys.public, machine_before);
280 assert_eq!(state.network_lock_keys.public, lock_before);
281 }
282
283 #[test]
284 fn node_state_two_rotations_without_success_preserve_original_anchor() {
285 // Two rotations WITHOUT an intervening successful register (no `clear_old_node_key`) must keep
286 // the ORIGINAL pre-expiry key as `old_node_key`, never overwrite it with the intermediate
287 // rotated key. Control links a rotation against the key it already knows (the last registered
288 // one), so a second rotation before recovery must not lose that anchor — the core of the
289 // "preserve the original OldNodeKey anchor" fix.
290 let mut state = NodeState::generate();
291 let original = state.node_keys.public;
292
293 state.rotate_node_key();
294 let intermediate = state.node_keys.public;
295 assert_eq!(
296 state.old_node_key,
297 Some(original),
298 "first rotation anchors on the original key"
299 );
300 assert_ne!(intermediate, original);
301
302 state.rotate_node_key();
303 assert_eq!(
304 state.old_node_key,
305 Some(original),
306 "a SECOND rotation before a successful register must preserve the ORIGINAL anchor, not \
307 overwrite it with the intermediate key"
308 );
309 assert_ne!(
310 state.node_keys.public, intermediate,
311 "the node key still rotates fresh on the second rotation"
312 );
313 assert_ne!(state.node_keys.public, original);
314 }
315
316 #[test]
317 fn node_state_rotation_after_clear_captures_new_current_key() {
318 // After a successful register clears the anchor (`clear_old_node_key`), the NEXT genuine
319 // rotation must capture the now-current (already-registered) key as the fresh anchor — not the
320 // long-gone original. This is the other half of the anchor lifecycle: clear-on-success lets a
321 // later expiry cycle re-anchor correctly.
322 let mut state = NodeState::generate();
323 let original = state.node_keys.public;
324
325 state.rotate_node_key();
326 let current = state.node_keys.public;
327 assert_eq!(state.old_node_key, Some(original));
328
329 // A successful register confirms `current` as the node's known identity at control.
330 state.clear_old_node_key();
331 assert_eq!(
332 state.old_node_key, None,
333 "a successful register releases the anchor"
334 );
335
336 // The next genuine expiry cycle rotates again — now anchoring on `current`, not `original`.
337 state.rotate_node_key();
338 assert_eq!(
339 state.old_node_key,
340 Some(current),
341 "a rotation AFTER a success captures the new current (registered) key as the anchor"
342 );
343 assert_ne!(state.node_keys.public, current);
344 }
345
346 #[test]
347 fn node_state_clear_old_node_key_is_noop_when_none() {
348 // The steady-state call: every successful map-poll re-register calls `clear_old_node_key`, and
349 // it must harmlessly do nothing while no anchor is held (and leave the live keys untouched).
350 let mut state = NodeState::generate();
351 let node_before = state.node_keys.public;
352 assert_eq!(state.old_node_key, None);
353
354 state.clear_old_node_key();
355
356 assert_eq!(state.old_node_key, None);
357 assert_eq!(
358 state.node_keys.public, node_before,
359 "clearing the anchor must not touch live keys"
360 );
361 }
362
363 #[test]
364 fn node_state_rotate_threads_to_persist_old_node_key() {
365 // After a runtime rotation, converting back to a PersistState carries the prior public key as
366 // old_node_key (so an embedder persisting the rotated state keeps the OldNodeKey linkage).
367 let mut state = NodeState::generate();
368 let before_pub = state.node_keys.public;
369 state.rotate_node_key();
370 let persist = PersistState::from(&state);
371 assert_eq!(persist.old_node_key, Some(before_pub));
372 assert_eq!(persist.node_key.public_key(), state.node_keys.public);
373 }
374
375 #[test]
376 fn node_state_threads_old_node_key() {
377 let mut persist = PersistState::default();
378 let some_pub = NodePrivateKey::random().public_key();
379 persist.old_node_key = Some(some_pub);
380
381 let node_state = NodeState::from(&persist);
382 assert_eq!(node_state.old_node_key, Some(some_pub));
383
384 let round_trip = PersistState::from(&node_state);
385 assert_eq!(round_trip.old_node_key, Some(some_pub));
386 }
387
388 #[test]
389 fn default_persist_state_has_no_old_key() {
390 assert!(PersistState::default().old_node_key.is_none());
391 }
392
393 #[cfg(feature = "serde")]
394 #[test]
395 fn persist_state_old_node_key_serde_default() {
396 // A default PersistState round-trips with no old key.
397 let json = serde_json::to_string(&PersistState::default()).unwrap();
398 let parsed: PersistState = serde_json::from_str(&json).unwrap();
399 assert!(parsed.old_node_key.is_none());
400
401 // A serialized form that OMITS `old_node_key` still deserializes (serde(default) →
402 // backward-compat with pre-rotation persisted state).
403 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
404 value
405 .as_object_mut()
406 .unwrap()
407 .remove("old_node_key")
408 .expect("default serializes the field");
409 let parsed: PersistState =
410 serde_json::from_value(value).expect("missing old_node_key deserializes via default");
411 assert!(parsed.old_node_key.is_none());
412 }
413
414 #[cfg(feature = "serde")]
415 #[test]
416 fn persist_state_acme_account_key_serde_default_and_round_trip() {
417 use alloc::vec;
418
419 // An old key file that OMITS `acme_account_key` still deserializes (serde(default) → None).
420 let json = serde_json::to_string(&PersistState::default()).unwrap();
421 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
422 value
423 .as_object_mut()
424 .unwrap()
425 .remove("acme_account_key")
426 .expect("default serializes the field");
427 let parsed: PersistState = serde_json::from_value(value)
428 .expect("missing acme_account_key deserializes via default");
429 assert!(parsed.acme_account_key.is_none());
430
431 // A `Some(der)` value round-trips through serde and across the NodeState conversions.
432 // The `Zeroizing` wrapper must NOT change the on-wire JSON: it serializes as the inner
433 // byte `Vec`, so the rendered JSON is identical to a bare `Vec<u8>`.
434 let state = PersistState {
435 acme_account_key: Some(zeroize::Zeroizing::new(vec![1u8, 2, 3, 4])),
436 ..Default::default()
437 };
438 let json = serde_json::to_string(&state).unwrap();
439 assert!(
440 json.contains("\"acme_account_key\":[1,2,3,4]"),
441 "Zeroizing must serialize as the bare byte array (unchanged JSON shape): {json}"
442 );
443 let parsed: PersistState = serde_json::from_str(&json).unwrap();
444 assert_eq!(
445 parsed.acme_account_key.as_deref().map(|v| v.as_slice()),
446 Some(&[1u8, 2, 3, 4][..])
447 );
448
449 let node_state = NodeState::from(&state);
450 assert_eq!(
451 node_state.acme_account_key.as_deref().map(|v| v.as_slice()),
452 Some(&[1u8, 2, 3, 4][..])
453 );
454 let round_trip = PersistState::from(&node_state);
455 assert_eq!(
456 round_trip.acme_account_key.as_deref().map(|v| v.as_slice()),
457 Some(&[1u8, 2, 3, 4][..])
458 );
459 }
460}