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 node_key: value.node_keys.private,
79 machine_key: value.machine_keys.private,
80 network_lock_key: value.network_lock_keys.private,
81 old_node_key: value.old_node_key,
82 acme_account_key: value.acme_account_key.clone(),
83 }
84 }
85}
86
87impl From<NodeState> for PersistState {
88 fn from(value: NodeState) -> Self {
89 Self::from(&value)
90 }
91}
92
93impl Default for PersistState {
94 fn default() -> Self {
95 Self {
96 machine_key: MachinePrivateKey::random(),
97 network_lock_key: NetworkLockPrivateKey::random(),
98 node_key: NodePrivateKey::random(),
99 old_node_key: None,
100 acme_account_key: None,
101 }
102 }
103}
104
105/// The complete runtime key state for a Tailscale node.
106#[derive(Clone, Default)]
107#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
108pub struct NodeState {
109 /// The [`DiscoKeyPair`] this Tailnet peer uses for the Disco protocol.
110 ///
111 /// These should be randomly generated for each run of a Tailscale device.
112 pub disco_keys: DiscoKeyPair,
113
114 /// The [`MachineKeyPair`] for the hardware this Tailnet peer runs on.
115 pub machine_keys: MachineKeyPair,
116
117 /// The [`NetworkLockKeyPair`] for this Tailnet peer, for use with Tailnet Lock.
118 pub network_lock_keys: NetworkLockKeyPair,
119
120 /// The [`NodeKeyPair`] for this Tailnet peer.
121 pub node_keys: NodeKeyPair,
122
123 /// The node's previous node public key during a rotation (see
124 /// [`PersistState::old_node_key`]). Threaded to registration as `RegisterRequest.OldNodeKey`.
125 #[cfg_attr(feature = "serde", serde(default))]
126 pub old_node_key: Option<NodePublicKey>,
127
128 /// The persisted ACME account key (PKCS#8 DER), threaded from
129 /// [`PersistState::acme_account_key`]. The `acme` cert-issuance path reads this to reuse the
130 /// same Let's Encrypt account across renewals. `None` when no ACME account is provisioned.
131 ///
132 /// Wrapped in [`Zeroizing`](zeroize::Zeroizing) so the DER private-key bytes are wiped from
133 /// memory on drop; serializes transparently via the inner `Vec` (unchanged JSON shape).
134 #[cfg_attr(feature = "serde", serde(default))]
135 pub acme_account_key: Option<zeroize::Zeroizing<alloc::vec::Vec<u8>>>,
136}
137
138impl Debug for NodeState {
139 fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
140 f.debug_tuple("NodeState")
141 .field(&self.machine_keys.public)
142 .field(&self.node_keys.public)
143 .field(&self.disco_keys.public)
144 .field(&self.network_lock_keys.public)
145 .finish()
146 }
147}
148
149impl Display for NodeState {
150 fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
151 Debug::fmt(self, f)
152 }
153}
154
155impl NodeState {
156 /// Generate a new [`NodeState`]. All keys get random values.
157 pub fn generate() -> Self {
158 Default::default()
159 }
160}
161
162impl From<&PersistState> for NodeState {
163 fn from(value: &PersistState) -> Self {
164 Self {
165 disco_keys: Default::default(),
166 node_keys: value.node_key.into(),
167 machine_keys: value.machine_key.into(),
168 network_lock_keys: value.network_lock_key.into(),
169 old_node_key: value.old_node_key,
170 acme_account_key: value.acme_account_key.clone(),
171 }
172 }
173}
174
175impl From<PersistState> for NodeState {
176 fn from(value: PersistState) -> Self {
177 Self::from(&value)
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn rotate_node_key_sets_old_and_fresh() {
187 let mut state = PersistState::default();
188 let before_pub = state.node_key.public_key();
189
190 state.rotate_node_key();
191
192 assert_eq!(state.old_node_key, Some(before_pub));
193 assert_ne!(state.node_key.public_key(), before_pub);
194 }
195
196 #[test]
197 fn node_state_threads_old_node_key() {
198 let mut persist = PersistState::default();
199 let some_pub = NodePrivateKey::random().public_key();
200 persist.old_node_key = Some(some_pub);
201
202 let node_state = NodeState::from(&persist);
203 assert_eq!(node_state.old_node_key, Some(some_pub));
204
205 let round_trip = PersistState::from(&node_state);
206 assert_eq!(round_trip.old_node_key, Some(some_pub));
207 }
208
209 #[test]
210 fn default_persist_state_has_no_old_key() {
211 assert!(PersistState::default().old_node_key.is_none());
212 }
213
214 #[cfg(feature = "serde")]
215 #[test]
216 fn persist_state_old_node_key_serde_default() {
217 // A default PersistState round-trips with no old key.
218 let json = serde_json::to_string(&PersistState::default()).unwrap();
219 let parsed: PersistState = serde_json::from_str(&json).unwrap();
220 assert!(parsed.old_node_key.is_none());
221
222 // A serialized form that OMITS `old_node_key` still deserializes (serde(default) →
223 // backward-compat with pre-rotation persisted state).
224 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
225 value
226 .as_object_mut()
227 .unwrap()
228 .remove("old_node_key")
229 .expect("default serializes the field");
230 let parsed: PersistState =
231 serde_json::from_value(value).expect("missing old_node_key deserializes via default");
232 assert!(parsed.old_node_key.is_none());
233 }
234
235 #[cfg(feature = "serde")]
236 #[test]
237 fn persist_state_acme_account_key_serde_default_and_round_trip() {
238 use alloc::vec;
239
240 // An old key file that OMITS `acme_account_key` still deserializes (serde(default) → None).
241 let json = serde_json::to_string(&PersistState::default()).unwrap();
242 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
243 value
244 .as_object_mut()
245 .unwrap()
246 .remove("acme_account_key")
247 .expect("default serializes the field");
248 let parsed: PersistState = serde_json::from_value(value)
249 .expect("missing acme_account_key deserializes via default");
250 assert!(parsed.acme_account_key.is_none());
251
252 // A `Some(der)` value round-trips through serde and across the NodeState conversions.
253 // The `Zeroizing` wrapper must NOT change the on-wire JSON: it serializes as the inner
254 // byte `Vec`, so the rendered JSON is identical to a bare `Vec<u8>`.
255 let state = PersistState {
256 acme_account_key: Some(zeroize::Zeroizing::new(vec![1u8, 2, 3, 4])),
257 ..Default::default()
258 };
259 let json = serde_json::to_string(&state).unwrap();
260 assert!(
261 json.contains("\"acme_account_key\":[1,2,3,4]"),
262 "Zeroizing must serialize as the bare byte array (unchanged JSON shape): {json}"
263 );
264 let parsed: PersistState = serde_json::from_str(&json).unwrap();
265 assert_eq!(
266 parsed.acme_account_key.as_deref().map(|v| v.as_slice()),
267 Some(&[1u8, 2, 3, 4][..])
268 );
269
270 let node_state = NodeState::from(&state);
271 assert_eq!(
272 node_state.acme_account_key.as_deref().map(|v| v.as_slice()),
273 Some(&[1u8, 2, 3, 4][..])
274 );
275 let round_trip = PersistState::from(&node_state);
276 assert_eq!(
277 round_trip.acme_account_key.as_deref().map(|v| v.as_slice()),
278 Some(&[1u8, 2, 3, 4][..])
279 );
280 }
281}