Skip to main content

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}