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
165impl From<&PersistState> for NodeState {
166 fn from(value: &PersistState) -> Self {
167 Self {
168 disco_keys: Default::default(),
169 // `.clone().into()`: building each keypair consumes a private key, which can no longer
170 // be `Copy`-d out of the `&PersistState`. Clone the stored key, then derive the pair
171 // (the pair's public half is computed from a borrow inside `From<$private>`).
172 node_keys: value.node_key.clone().into(),
173 machine_keys: value.machine_key.clone().into(),
174 network_lock_keys: value.network_lock_key.clone().into(),
175 old_node_key: value.old_node_key,
176 acme_account_key: value.acme_account_key.clone(),
177 }
178 }
179}
180
181impl From<PersistState> for NodeState {
182 fn from(value: PersistState) -> Self {
183 Self::from(&value)
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn rotate_node_key_sets_old_and_fresh() {
193 let mut state = PersistState::default();
194 let before_pub = state.node_key.public_key();
195
196 state.rotate_node_key();
197
198 assert_eq!(state.old_node_key, Some(before_pub));
199 assert_ne!(state.node_key.public_key(), before_pub);
200 }
201
202 #[test]
203 fn node_state_threads_old_node_key() {
204 let mut persist = PersistState::default();
205 let some_pub = NodePrivateKey::random().public_key();
206 persist.old_node_key = Some(some_pub);
207
208 let node_state = NodeState::from(&persist);
209 assert_eq!(node_state.old_node_key, Some(some_pub));
210
211 let round_trip = PersistState::from(&node_state);
212 assert_eq!(round_trip.old_node_key, Some(some_pub));
213 }
214
215 #[test]
216 fn default_persist_state_has_no_old_key() {
217 assert!(PersistState::default().old_node_key.is_none());
218 }
219
220 #[cfg(feature = "serde")]
221 #[test]
222 fn persist_state_old_node_key_serde_default() {
223 // A default PersistState round-trips with no old key.
224 let json = serde_json::to_string(&PersistState::default()).unwrap();
225 let parsed: PersistState = serde_json::from_str(&json).unwrap();
226 assert!(parsed.old_node_key.is_none());
227
228 // A serialized form that OMITS `old_node_key` still deserializes (serde(default) →
229 // backward-compat with pre-rotation persisted state).
230 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
231 value
232 .as_object_mut()
233 .unwrap()
234 .remove("old_node_key")
235 .expect("default serializes the field");
236 let parsed: PersistState =
237 serde_json::from_value(value).expect("missing old_node_key deserializes via default");
238 assert!(parsed.old_node_key.is_none());
239 }
240
241 #[cfg(feature = "serde")]
242 #[test]
243 fn persist_state_acme_account_key_serde_default_and_round_trip() {
244 use alloc::vec;
245
246 // An old key file that OMITS `acme_account_key` still deserializes (serde(default) → None).
247 let json = serde_json::to_string(&PersistState::default()).unwrap();
248 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
249 value
250 .as_object_mut()
251 .unwrap()
252 .remove("acme_account_key")
253 .expect("default serializes the field");
254 let parsed: PersistState = serde_json::from_value(value)
255 .expect("missing acme_account_key deserializes via default");
256 assert!(parsed.acme_account_key.is_none());
257
258 // A `Some(der)` value round-trips through serde and across the NodeState conversions.
259 // The `Zeroizing` wrapper must NOT change the on-wire JSON: it serializes as the inner
260 // byte `Vec`, so the rendered JSON is identical to a bare `Vec<u8>`.
261 let state = PersistState {
262 acme_account_key: Some(zeroize::Zeroizing::new(vec![1u8, 2, 3, 4])),
263 ..Default::default()
264 };
265 let json = serde_json::to_string(&state).unwrap();
266 assert!(
267 json.contains("\"acme_account_key\":[1,2,3,4]"),
268 "Zeroizing must serialize as the bare byte array (unchanged JSON shape): {json}"
269 );
270 let parsed: PersistState = serde_json::from_str(&json).unwrap();
271 assert_eq!(
272 parsed.acme_account_key.as_deref().map(|v| v.as_slice()),
273 Some(&[1u8, 2, 3, 4][..])
274 );
275
276 let node_state = NodeState::from(&state);
277 assert_eq!(
278 node_state.acme_account_key.as_deref().map(|v| v.as_slice()),
279 Some(&[1u8, 2, 3, 4][..])
280 );
281 let round_trip = PersistState::from(&node_state);
282 assert_eq!(
283 round_trip.acme_account_key.as_deref().map(|v| v.as_slice()),
284 Some(&[1u8, 2, 3, 4][..])
285 );
286 }
287}