ts_keys/lib.rs
1#![doc = include_str!("../README.md")]
2#![no_std]
3
4extern crate alloc;
5
6mod keystate;
7mod macros;
8
9#[doc(inline)]
10pub use keystate::{NodeState, PersistState};
11use macros::{
12 _create_x25519_base_key_type, create_ed25519_keypair_types, create_ed25519_private_key_type,
13 create_ed25519_public_key_type, create_x25519_keypair_types, create_x25519_private_key_type,
14 create_x25519_public_key_type,
15};
16
17/// Errors that may occur when parsing a string into a key type.
18#[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum ParseError {
20 /// Key string was formatted incorrectly.
21 #[error("key string was formatted incorrectly")]
22 InvalidFormat,
23
24 /// Key was the wrong length.
25 #[error("key was the wrong length")]
26 WrongLength,
27
28 /// Parsed prefix did not match the key type.
29 #[error("parsed prefix did not match the key type")]
30 BadPrefix,
31}
32
33// The client never handles challenge private keys, so we only create a public key type rather than
34// public/private/keypair types.
35create_x25519_public_key_type!(
36 /// The X25519 public key of a challenge issued by control to a Tailnet node during registration.
37 ChallengePublicKey,
38 "chalpub"
39);
40
41// The client never handles DERP server private keys, so we only create a public key type rather
42// than public/private/keypair types.
43create_x25519_public_key_type!(
44 /// The X25519 public key of a DERP server.
45 DerpServerPublicKey,
46 "derp"
47);
48create_x25519_keypair_types!(
49 /// The X25519 public key a Tailscale node uses for the Disco protocol.
50 DiscoPublicKey,
51 "discokey",
52 /// The X25519 private key a Tailscale node uses for the Disco protocol.
53 DiscoPrivateKey,
54 "privkey",
55 /// The X25519 public/private key pair a Tailscale node uses for the Disco protocol.
56 DiscoKeyPair
57);
58
59create_x25519_keypair_types!(
60 /// The X25519 public key of a unique piece of hardware running one or more Tailscale nodes.
61 /// Also the key type sent from a control server to a Tailscale node during the initial control
62 /// handshake.
63 MachinePublicKey,
64 "mkey",
65 /// The X25519 private key of a unique piece of hardware running one or more Tailscale nodes.
66 MachinePrivateKey,
67 "privkey",
68 /// The X25519 public/private key pair of a unique piece of hardware running one or more
69 /// Tailscale nodes.
70 MachineKeyPair
71);
72
73create_ed25519_keypair_types!(
74 /// The Ed25519 public key of a Tailscale node for use with Tailnet Lock.
75 NetworkLockPublicKey,
76 "nlpub",
77 /// The Ed25519 private key of a Tailscale node for use with Tailnet Lock.
78 NetworkLockPrivateKey,
79 "nlpriv",
80 /// The Ed25519 public/private key pair of a Tailscale node for use with Tailnet Lock.
81 NetworkLockKeyPair
82);
83
84create_x25519_keypair_types!(
85 /// The X25519 public key of a Tailscale node.
86 NodePublicKey,
87 "nodekey",
88 /// The X25519 private key of a Tailscale node.
89 NodePrivateKey,
90 "privkey",
91 /// The X25519 public/private key pair of a Tailscale node.
92 NodeKeyPair
93);
94
95/// The leading key bytes stamped over an expired node's public key so it base64s to a `bad01`
96/// ("bad ol'") prefix — Go `key.badOldPrefix` (`types/key/node.go`, upstream issue #6932).
97///
98/// The marker is purely a debugging aid: it makes an intentionally-broken expired node key jump
99/// out of a log or a `tailscale status` dump instead of looking like an ordinary key.
100const BAD_OLD_PREFIX: [u8; 6] = [109, 167, 116, 213, 215, 116];
101
102/// Break a node's public key so nothing can communicate with it, returning a copy of `key` whose
103/// leading bytes are replaced by the `bad01` marker — Go `key.NodePublicWithBadOldPrefix`
104/// (`types/key/node.go`).
105///
106/// Used as defence in depth when a peer's key expiry has passed: the expired peer stays in the
107/// netmap (so callers can report *why* it is unreachable rather than "no such peer"), but the key
108/// left on it can no longer complete a WireGuard handshake. See `ts_control`'s `ExpiryManager`.
109///
110/// **Idempotent**: the transform overwrites the first six bytes rather than mixing them, so
111/// re-applying it to an already-broken key yields the same key. That matters because the peer
112/// tracker re-runs the expiry pass on a timer over peers it may have already flagged.
113pub fn node_public_with_bad_old_prefix(key: NodePublicKey) -> NodePublicKey {
114 let mut raw = key.to_bytes();
115 raw[..BAD_OLD_PREFIX.len()].copy_from_slice(&BAD_OLD_PREFIX);
116 NodePublicKey::from(raw)
117}
118
119#[cfg(test)]
120mod bad_old_prefix_tests {
121 use super::{BAD_OLD_PREFIX, NodePublicKey, node_public_with_bad_old_prefix};
122
123 /// The transform stamps exactly the first six bytes and leaves the tail alone, so two distinct
124 /// expired peers keep distinct (broken) keys and never collide in a node-key index.
125 #[test]
126 fn stamps_the_prefix_and_keeps_the_tail() {
127 let key = NodePublicKey::from([0x11u8; 32]);
128 let broken = node_public_with_bad_old_prefix(key).to_bytes();
129
130 assert_eq!(&broken[..6], &BAD_OLD_PREFIX);
131 assert_eq!(&broken[6..], &[0x11u8; 26]);
132 assert_ne!(broken, key.to_bytes());
133
134 let other = node_public_with_bad_old_prefix(NodePublicKey::from([0x22u8; 32])).to_bytes();
135 assert_ne!(broken, other);
136 }
137
138 /// Re-flagging an already-flagged peer must not double-mangle the key: the timer pass in
139 /// `ts_runtime`'s peer tracker can revisit a peer it already broke.
140 #[test]
141 fn is_idempotent() {
142 let once = node_public_with_bad_old_prefix(NodePublicKey::from([0x11u8; 32]));
143 let twice = node_public_with_bad_old_prefix(once);
144
145 assert_eq!(once, twice);
146 }
147}
148
149#[cfg(test)]
150mod debug_redaction_tests {
151 use alloc::format;
152
153 use super::{
154 DiscoPrivateKey, MachinePrivateKey, NetworkLockPrivateKey, NodePrivateKey, NodePublicKey,
155 };
156
157 /// A private key's `Debug` MUST NOT contain the secret bytes (regression guard for the
158 /// log-leak fixed in tsr-9nu). We use an all-`0xAB` key so the hex `"ab"` is unmistakable.
159 #[test]
160 fn private_key_debug_is_redacted() {
161 let secret = [0xABu8; 32];
162
163 let m = MachinePrivateKey::from(secret);
164 let n = NodePrivateKey::from(secret);
165 let d = DiscoPrivateKey::from(secret);
166 let nl = NetworkLockPrivateKey::from(secret);
167
168 for (label, dbg) in [
169 ("MachinePrivateKey", format!("{m:?}")),
170 ("NodePrivateKey", format!("{n:?}")),
171 ("DiscoPrivateKey", format!("{d:?}")),
172 ("NetworkLockPrivateKey", format!("{nl:?}")),
173 ] {
174 assert!(
175 dbg.contains("<redacted>"),
176 "{label} Debug should be redacted, got {dbg:?}"
177 );
178 assert!(
179 !dbg.contains("abab"),
180 "{label} Debug leaked secret bytes: {dbg:?}"
181 );
182 // The secret is also reachable via Display/to_bytes — confirm those still expose it,
183 // so the redaction is Debug-only and didn't break the explicit serialization paths.
184 assert!(
185 format!("{m}").contains("abab"),
186 "Display must still expose the key bytes"
187 );
188 }
189 }
190
191 /// A public key's `Debug` SHOULD still print the full `prefix:hex` (public keys are not secret).
192 #[test]
193 fn public_key_debug_shows_hex() {
194 let pubk = NodePublicKey::from([0xABu8; 32]);
195 let dbg = format!("{pubk:?}");
196 assert!(
197 dbg.contains("abab"),
198 "public key Debug should show hex: {dbg:?}"
199 );
200 assert_eq!(dbg, format!("{pubk}"), "public Debug == Display");
201 }
202
203 /// Private keys wipe their secret bytes on drop (`ZeroizeOnDrop`, tsr-9nu). We can't observe a
204 /// value after it drops in safe Rust, so this drives `Zeroize::zeroize` explicitly (the same
205 /// code the drop glue runs) and confirms the buffer is zeroed — a behavioral guard that the
206 /// derive is wired up, not merely that it compiles.
207 #[test]
208 fn private_key_zeroize_wipes_bytes() {
209 use zeroize::Zeroize;
210
211 let mut k = NodePrivateKey::from([0xABu8; 32]);
212 assert_eq!(
213 k.to_bytes(),
214 [0xABu8; 32],
215 "precondition: key holds its bytes"
216 );
217 k.zeroize();
218 assert_eq!(
219 k.to_bytes(),
220 [0u8; 32],
221 "zeroize must wipe the secret bytes to zero"
222 );
223 }
224
225 /// `public_key()` borrows (`&self`) — deriving the public key must not consume the private key,
226 /// so it stays usable afterwards. This is the API shape that lets callers hold a private key
227 /// without it being moved/dropped on every derivation (mirrors Go's `key.NodePrivate.Public()`).
228 #[test]
229 fn public_key_derivation_borrows_private() {
230 let k = NodePrivateKey::from([0x11u8; 32]);
231 let p1 = k.public_key();
232 // `k` is still alive here precisely because `public_key` took `&self`.
233 let p2 = k.public_key();
234 assert_eq!(p1, p2, "repeated derivation from the same key agrees");
235 // And a clone derives the same public key (clone copies the secret faithfully).
236 assert_eq!(k.clone().public_key(), p1);
237 }
238
239 /// A key string of the right length+prefix but containing non-hex (or a non-ASCII char that
240 /// splits a 2-byte window) must parse to `Err`, NOT panic. Regression: the hex loop used
241 /// `.unwrap()` on `get(i..i+2)` and `from_str_radix`, so a malformed key in a control response
242 /// would unwind and kill the netmap decoder. Go's key parse returns an error here.
243 #[test]
244 fn malformed_hex_key_errors_not_panics() {
245 use core::str::FromStr;
246
247 // 64 non-hex ASCII chars: length + prefix pass, `from_str_radix("zz",16)` must error.
248 let non_hex = alloc::format!("nodekey:{}", "z".repeat(64));
249 assert!(
250 NodePublicKey::from_str(&non_hex).is_err(),
251 "non-hex key body must be a parse error, not a panic"
252 );
253
254 // A multi-byte UTF-8 char makes the byte length 64 while `get(i..i+2)` can land on a char
255 // boundary and return None — must also be an error, not a panic. "é" is 2 UTF-8 bytes.
256 let multibyte = alloc::format!("nodekey:{}", "é".repeat(32));
257 assert_eq!(multibyte.len() - "nodekey:".len(), 64, "body is 64 bytes");
258 assert!(
259 NodePublicKey::from_str(&multibyte).is_err(),
260 "a non-ASCII body must be a parse error, not a panic"
261 );
262 }
263}
264
265#[cfg(all(test, feature = "serde"))]
266mod nl_tests {
267 use core::str::FromStr;
268
269 use super::{NetworkLockKeyPair, NetworkLockPrivateKey, NetworkLockPublicKey};
270
271 /// A `NetworkLockKeyPair` round-trips through its `nlpriv:`/`nlpub:` string forms.
272 #[test]
273 fn nl_key_roundtrip_serde() {
274 let kp = NetworkLockKeyPair::new();
275
276 let priv_str = alloc::format!("{}", kp.private);
277 let pub_str = alloc::format!("{}", kp.public);
278 assert!(priv_str.starts_with("nlpriv:"));
279 assert!(pub_str.starts_with("nlpub:"));
280
281 let parsed_priv = NetworkLockPrivateKey::from_str(&priv_str).unwrap();
282 let parsed_pub = NetworkLockPublicKey::from_str(&pub_str).unwrap();
283 assert_eq!(parsed_priv, kp.private);
284 assert_eq!(parsed_pub, kp.public);
285 }
286
287 /// Public-key derivation is deterministic and matches the audited ed25519-dalek RFC 8032
288 /// seed->public derivation (proving we are NOT using X25519 scalar multiplication).
289 #[test]
290 fn nl_public_derivation_is_deterministic() {
291 let seed = [7u8; 32];
292 let sk = NetworkLockPrivateKey::from(seed);
293 let p1 = sk.public_key();
294 let p2 = sk.public_key();
295 assert_eq!(p1, p2);
296
297 let dalek = ed25519_dalek::SigningKey::from_bytes(&seed)
298 .verifying_key()
299 .to_bytes();
300 assert_eq!(p1.to_bytes(), dalek);
301 }
302
303 /// RFC 8032 §7.1 TEST 1 known-answer vector, exercised through the fork's own
304 /// `NetworkLockPrivateKey`: the canonical Ed25519 secret seed must derive the canonical public
305 /// key. This pins the fork's NL key to standards-conformant Ed25519 (RFC 8032) — a stronger proof
306 /// than the dalek cross-check, since it would catch a byte-swap, a wrong seed interpretation, or a
307 /// future dependency that derived a different curve. Vector verified against rfc-editor.org/rfc/rfc8032.txt.
308 #[test]
309 fn nl_public_matches_rfc8032_test1() {
310 fn unhex(s: &str) -> [u8; 32] {
311 let b = s.as_bytes();
312 let mut out = [0u8; 32];
313 let mut i = 0;
314 while i < 32 {
315 let hi = (b[2 * i] as char).to_digit(16).unwrap() as u8;
316 let lo = (b[2 * i + 1] as char).to_digit(16).unwrap() as u8;
317 out[i] = (hi << 4) | lo;
318 i += 1;
319 }
320 out
321 }
322 let seed = unhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60");
323 let public = unhex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
324 let derived = NetworkLockPrivateKey::from(seed).public_key();
325 assert_eq!(derived.to_bytes(), public);
326
327 // Also lock in the full `nlpub:`+lowercase-hex emission (prefix + hex jointly), which is the
328 // exact text form Go sends as `RegisterRequest.NLKey` (`key.NLPublic.MarshalText`).
329 assert_eq!(
330 alloc::format!("{derived}"),
331 "nlpub:d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
332 );
333 }
334
335 /// `KeyPair::new`, `From<private>`, and the standalone `private.public_key()` all agree on the
336 /// derived public — there is one derivation, regardless of how the pair is constructed.
337 #[test]
338 fn nl_keypair_derivation_is_consistent() {
339 let kp = NetworkLockKeyPair::new();
340 assert_eq!(kp.public, kp.private.public_key());
341 // `.clone()`: `From<private>` consumes the key (no longer `Copy`); keep `kp.private` for
342 // the equality check below.
343 let from_priv = NetworkLockKeyPair::from(kp.private.clone());
344 assert_eq!(from_priv.public, kp.public);
345 assert_eq!(from_priv.private, kp.private);
346 }
347
348 /// Regression guard: the Ed25519 derivation must NOT match the old (buggy) X25519 derivation
349 /// for the same 32-byte seed.
350 #[test]
351 fn nl_key_is_not_x25519() {
352 let seed = [7u8; 32];
353 let ed = NetworkLockPrivateKey::from(seed).public_key().to_bytes();
354 let x = x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(seed)).to_bytes();
355 assert_ne!(ed, x);
356 }
357}