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            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}