Skip to main content

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#[cfg(test)]
96mod debug_redaction_tests {
97    use alloc::format;
98
99    use super::{
100        DiscoPrivateKey, MachinePrivateKey, NetworkLockPrivateKey, NodePrivateKey, NodePublicKey,
101    };
102
103    /// A private key's `Debug` MUST NOT contain the secret bytes (regression guard for the
104    /// log-leak fixed in tsr-9nu). We use an all-`0xAB` key so the hex `"ab"` is unmistakable.
105    #[test]
106    fn private_key_debug_is_redacted() {
107        let secret = [0xABu8; 32];
108
109        let m = MachinePrivateKey::from(secret);
110        let n = NodePrivateKey::from(secret);
111        let d = DiscoPrivateKey::from(secret);
112        let nl = NetworkLockPrivateKey::from(secret);
113
114        for (label, dbg) in [
115            ("MachinePrivateKey", format!("{m:?}")),
116            ("NodePrivateKey", format!("{n:?}")),
117            ("DiscoPrivateKey", format!("{d:?}")),
118            ("NetworkLockPrivateKey", format!("{nl:?}")),
119        ] {
120            assert!(
121                dbg.contains("<redacted>"),
122                "{label} Debug should be redacted, got {dbg:?}"
123            );
124            assert!(
125                !dbg.contains("abab"),
126                "{label} Debug leaked secret bytes: {dbg:?}"
127            );
128            // The secret is also reachable via Display/to_bytes โ€” confirm those still expose it,
129            // so the redaction is Debug-only and didn't break the explicit serialization paths.
130            assert!(
131                format!("{m}").contains("abab"),
132                "Display must still expose the key bytes"
133            );
134        }
135    }
136
137    /// A public key's `Debug` SHOULD still print the full `prefix:hex` (public keys are not secret).
138    #[test]
139    fn public_key_debug_shows_hex() {
140        let pubk = NodePublicKey::from([0xABu8; 32]);
141        let dbg = format!("{pubk:?}");
142        assert!(
143            dbg.contains("abab"),
144            "public key Debug should show hex: {dbg:?}"
145        );
146        assert_eq!(dbg, format!("{pubk}"), "public Debug == Display");
147    }
148}
149
150#[cfg(all(test, feature = "serde"))]
151mod nl_tests {
152    use core::str::FromStr;
153
154    use super::{NetworkLockKeyPair, NetworkLockPrivateKey, NetworkLockPublicKey};
155
156    /// A `NetworkLockKeyPair` round-trips through its `nlpriv:`/`nlpub:` string forms.
157    #[test]
158    fn nl_key_roundtrip_serde() {
159        let kp = NetworkLockKeyPair::new();
160
161        let priv_str = alloc::format!("{}", kp.private);
162        let pub_str = alloc::format!("{}", kp.public);
163        assert!(priv_str.starts_with("nlpriv:"));
164        assert!(pub_str.starts_with("nlpub:"));
165
166        let parsed_priv = NetworkLockPrivateKey::from_str(&priv_str).unwrap();
167        let parsed_pub = NetworkLockPublicKey::from_str(&pub_str).unwrap();
168        assert_eq!(parsed_priv, kp.private);
169        assert_eq!(parsed_pub, kp.public);
170    }
171
172    /// Public-key derivation is deterministic and matches the audited ed25519-dalek RFC 8032
173    /// seed->public derivation (proving we are NOT using X25519 scalar multiplication).
174    #[test]
175    fn nl_public_derivation_is_deterministic() {
176        let seed = [7u8; 32];
177        let sk = NetworkLockPrivateKey::from(seed);
178        let p1 = sk.public_key();
179        let p2 = sk.public_key();
180        assert_eq!(p1, p2);
181
182        let dalek = ed25519_dalek::SigningKey::from_bytes(&seed)
183            .verifying_key()
184            .to_bytes();
185        assert_eq!(p1.to_bytes(), dalek);
186    }
187
188    /// RFC 8032 ยง7.1 TEST 1 known-answer vector, exercised through the fork's own
189    /// `NetworkLockPrivateKey`: the canonical Ed25519 secret seed must derive the canonical public
190    /// key. This pins the fork's NL key to standards-conformant Ed25519 (RFC 8032) โ€” a stronger proof
191    /// than the dalek cross-check, since it would catch a byte-swap, a wrong seed interpretation, or a
192    /// future dependency that derived a different curve. Vector verified against rfc-editor.org/rfc/rfc8032.txt.
193    #[test]
194    fn nl_public_matches_rfc8032_test1() {
195        fn unhex(s: &str) -> [u8; 32] {
196            let b = s.as_bytes();
197            let mut out = [0u8; 32];
198            let mut i = 0;
199            while i < 32 {
200                let hi = (b[2 * i] as char).to_digit(16).unwrap() as u8;
201                let lo = (b[2 * i + 1] as char).to_digit(16).unwrap() as u8;
202                out[i] = (hi << 4) | lo;
203                i += 1;
204            }
205            out
206        }
207        let seed = unhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60");
208        let public = unhex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
209        let derived = NetworkLockPrivateKey::from(seed).public_key();
210        assert_eq!(derived.to_bytes(), public);
211
212        // Also lock in the full `nlpub:`+lowercase-hex emission (prefix + hex jointly), which is the
213        // exact text form Go sends as `RegisterRequest.NLKey` (`key.NLPublic.MarshalText`).
214        assert_eq!(
215            alloc::format!("{derived}"),
216            "nlpub:d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
217        );
218    }
219
220    /// `KeyPair::new`, `From<private>`, and the standalone `private.public_key()` all agree on the
221    /// derived public โ€” there is one derivation, regardless of how the pair is constructed.
222    #[test]
223    fn nl_keypair_derivation_is_consistent() {
224        let kp = NetworkLockKeyPair::new();
225        assert_eq!(kp.public, kp.private.public_key());
226        let from_priv = NetworkLockKeyPair::from(kp.private);
227        assert_eq!(from_priv.public, kp.public);
228        assert_eq!(from_priv.private, kp.private);
229    }
230
231    /// Regression guard: the Ed25519 derivation must NOT match the old (buggy) X25519 derivation
232    /// for the same 32-byte seed.
233    #[test]
234    fn nl_key_is_not_x25519() {
235        let seed = [7u8; 32];
236        let ed = NetworkLockPrivateKey::from(seed).public_key().to_bytes();
237        let x = x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(seed)).to_bytes();
238        assert_ne!(ed, x);
239    }
240}