Skip to main content

ts_control_serde/
tka_bootstrap.rs

1//! Wire types for the Tailnet-Lock (TKA) **bootstrap** RPC (`GET /machine/tka/bootstrap`,
2//! Noise-tunnelled).
3//!
4//! Bootstrap is the *entry* to TKA sync: before a node can run the offer/send catch-up handshake it
5//! needs an initial chain to offer. It sends control its current head (empty on first run) and
6//! control returns the **genesis AUM** — the node verifies it (`ts_tka::VerifiedAumChain::verify`)
7//! and builds its initial `Authority` from it, then syncs forward to the current head.
8//!
9//! Mirrors Go `tailcfg.TKABootstrapRequest` / `TKABootstrapResponse` (`tailcfg/tka.go`, v1.100.0).
10//! Wire encodings:
11//! - `Head` is the node's current head as a base32 (no-pad) AUM-hash string, **empty** when the node
12//!   has no chain yet (Go: "if tailnet lock is enabled"). Carried as `String`.
13//! - `GenesisAUM` (Go `tkatype.MarshaledAUM` = `[]byte`, `json:",omitempty"`) and `DisablementSecret`
14//!   (Go `[]byte`, `json:",omitempty"`) are **single** values that Go's `encoding/json`
15//!   base64-encodes (not arrays, unlike the sync `MissingAUMs`). The [`marshaled_bytes`] serde module
16//!   maps `Vec<u8>` ⇄ a single standard-base64 string, and an absent/empty field ⇄ an empty `Vec`.
17
18use alloc::{
19    string::{String, ToString},
20    vec::Vec,
21};
22
23use base64::{Engine, engine::general_purpose::STANDARD};
24use serde::{Deserialize, Serialize};
25use ts_capabilityversion::CapabilityVersion;
26use ts_keys::NodePublicKey;
27
28/// Request body for `GET /machine/tka/bootstrap` (Go `tailcfg.TKABootstrapRequest`): ask control for
29/// the genesis AUM needed to initialize TKA.
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "PascalCase")]
32pub struct TkaBootstrapRequest {
33    /// Client capability version (serializes as `Version`).
34    pub version: CapabilityVersion,
35    /// This node's public key (serializes as `NodeKey` → `nodekey:`+hex).
36    pub node_key: NodePublicKey,
37    /// The node's current head, base32 (no-pad) AUM-hash text — **empty** when TKA is not yet
38    /// initialized locally (the first-run case).
39    pub head: String,
40}
41
42/// Response to `GET /machine/tka/bootstrap` (Go `tailcfg.TKABootstrapResponse`): the genesis AUM (so
43/// the node can build its initial `Authority`) and the disablement secret.
44#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "PascalCase")]
46pub struct TkaBootstrapResponse {
47    /// The initial AUM needed to initialize TKA — raw CBOR bytes, base64 on the wire (Go
48    /// `tkatype.MarshaledAUM`, `omitempty`). Empty when control sends none (e.g. TKA not enabled).
49    #[serde(rename = "GenesisAUM", with = "marshaled_bytes", default)]
50    pub genesis_aum: Vec<u8>,
51    /// A secret needed to disable TKA — base64 on the wire (Go `[]byte`, `omitempty`). Not used by a
52    /// read-only sync client, but decoded for completeness. Empty when absent.
53    #[serde(with = "marshaled_bytes", default)]
54    pub disablement_secret: Vec<u8>,
55}
56
57/// Serde for a single Go `[]byte` (`omitempty`): a standard-base64 JSON string ⇄ `Vec<u8>`. Go's
58/// `encoding/json` base64-encodes a `[]byte`; an absent or `null` field decodes to the empty `Vec`
59/// (the field's zero value), and an empty `Vec` serializes to an empty base64 string. Distinct from
60/// `tka_sync`'s `marshaled_aums`, which handles an *array* of such values.
61mod marshaled_bytes {
62    use super::*;
63
64    pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
65    where
66        S: serde::Serializer,
67    {
68        serializer.serialize_str(&STANDARD.encode(bytes))
69    }
70
71    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
72    where
73        D: serde::Deserializer<'de>,
74    {
75        let s: Option<String> = Option::deserialize(deserializer)?;
76        let Some(s) = s else {
77            return Ok(Vec::new());
78        };
79        if s.is_empty() {
80            return Ok(Vec::new());
81        }
82        STANDARD
83            .decode(s.as_bytes())
84            .map_err(|e| serde::de::Error::custom(e.to_string()))
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn bootstrap_request_pascalcase_nodekey_and_head() {
94        let req = TkaBootstrapRequest {
95            version: CapabilityVersion::CURRENT,
96            node_key: NodePublicKey::from([7u8; 32]),
97            head: String::new(), // first-run: empty head
98        };
99        let json = serde_json::to_value(&req).unwrap();
100        assert!(json.get("Version").is_some());
101        assert_eq!(json.get("Head").and_then(|v| v.as_str()), Some(""));
102        let nk = json.get("NodeKey").and_then(|v| v.as_str()).unwrap();
103        assert!(
104            nk.starts_with("nodekey:"),
105            "NodeKey is nodekey:+hex, got {nk}"
106        );
107    }
108
109    #[test]
110    fn bootstrap_response_genesis_aum_is_base64() {
111        // GenesisAUM {1,2,3} must encode as a single base64 string "AQID" and round-trip.
112        let resp = TkaBootstrapResponse {
113            genesis_aum: alloc::vec![1u8, 2, 3],
114            disablement_secret: alloc::vec![0xffu8],
115        };
116        let json = serde_json::to_value(&resp).unwrap();
117        assert_eq!(
118            json.get("GenesisAUM").and_then(|v| v.as_str()),
119            Some("AQID")
120        );
121        assert_eq!(
122            json.get("DisablementSecret").and_then(|v| v.as_str()),
123            Some("/w==")
124        );
125        let back: TkaBootstrapResponse = serde_json::from_value(json).unwrap();
126        assert_eq!(back, resp);
127    }
128
129    #[test]
130    fn bootstrap_response_absent_genesis_is_empty() {
131        // Control sends nothing (TKA not enabled / omitempty) → empty Vec, not an error.
132        let resp: TkaBootstrapResponse = serde_json::from_str("{}").unwrap();
133        assert!(resp.genesis_aum.is_empty());
134        assert!(resp.disablement_secret.is_empty());
135        // Explicit null also decodes to empty.
136        let resp2: TkaBootstrapResponse =
137            serde_json::from_str(r#"{"GenesisAUM":null,"DisablementSecret":null}"#).unwrap();
138        assert!(resp2.genesis_aum.is_empty());
139    }
140
141    #[test]
142    fn bootstrap_request_roundtrips_with_head() {
143        let req = TkaBootstrapRequest {
144            version: CapabilityVersion::CURRENT,
145            node_key: NodePublicKey::from([3u8; 32]),
146            head: "MFRGGZDF".to_string(),
147        };
148        let json = serde_json::to_string(&req).unwrap();
149        let back: TkaBootstrapRequest = serde_json::from_str(&json).unwrap();
150        assert_eq!(back, req);
151    }
152}