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}