Skip to main content

ts_control_serde/
tka_mutation.rs

1//! Wire types for the Tailnet-Lock (TKA) **mutation** RPCs, all Noise-tunnelled `GET`-with-JSON-body
2//! to `/machine/tka/*` (same transport shape as the sync RPCs in [`crate::tka_sync`]):
3//!
4//! - `GET /machine/tka/init/begin`  — [`TkaInitBeginRequest`]  → [`TkaInitBeginResponse`]
5//! - `GET /machine/tka/init/finish` — [`TkaInitFinishRequest`] → [`TkaInitFinishResponse`]
6//! - `GET /machine/tka/sign`        — [`TkaSubmitSignatureRequest`] → [`TkaSubmitSignatureResponse`]
7//! - `GET /machine/tka/disable`     — [`TkaDisableRequest`] → [`TkaDisableResponse`]
8//!
9//! Mirrors Go `tailcfg.TKAInitBeginRequest`/`Response`, `TKAInitFinishRequest`/`Response`,
10//! `TKASubmitSignatureRequest`/`Response`, `TKADisableRequest`/`Response` and the `TKASignInfo`
11//! element (`tailcfg/tka.go`, v1.100.0). Go uses no `json:"..."` rename tags on these, so the wire
12//! field names are the Go field names verbatim → `PascalCase` here.
13//!
14//! Wire encodings to get byte-exact (Go `encoding/json` over the underlying types):
15//! - `Version` is `CapabilityVersion` (`int`) → plain JSON number.
16//! - `NodeKey`/`NodePublic` are `key.NodePublic` → the `nodekey:`+lowercase-hex string
17//!   ([`NodePublicKey`]'s own serde).
18//! - `GenesisAUM`/`Signature`/`RotationPubkey`/`SupportDisablement`/`DisablementSecret` are
19//!   `[]byte`-underlying (`tkatype.MarshaledAUM`/`MarshaledSignature`/plain `[]byte`), which Go's
20//!   `encoding/json` emits as **standard base64 (padded)** — the [`crate::marshaled_bytes`] module.
21//! - `Head` is a Go `string` already holding the **base32 (std alphabet, no padding)** text form of a
22//!   32-byte AUM hash (`tka.AUMHash.MarshalText`); carried as a `String` verbatim (the RPC client
23//!   converts to/from [`AumHash`](ts_tka)).
24//! - `Signatures` is Go `map[NodeID]tkatype.MarshaledSignature`. Go's `encoding/json` encodes an
25//!   integer-keyed map as a JSON object with **decimal-string keys** (e.g. `"42"`) → base64 values.
26//!   Modeled as a `BTreeMap<String, Vec<u8>>` (string keys = the decimal `NodeID`, base64 values) —
27//!   the faithful mirror, with deterministic key ordering.
28//! - `SupportDisablement` is the one `omitempty` field — skipped when empty so it disappears from the
29//!   JSON, matching Go.
30
31use alloc::{
32    collections::BTreeMap,
33    string::{String, ToString},
34    vec::Vec,
35};
36
37use base64::{Engine, engine::general_purpose::STANDARD};
38use serde::{Deserialize, Serialize};
39use ts_capabilityversion::CapabilityVersion;
40use ts_keys::NodePublicKey;
41
42/// Request body for `GET /machine/tka/init/begin` (Go `tailcfg.TKAInitBeginRequest`): the node
43/// proposes the genesis AUM that would establish the lock.
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "PascalCase")]
46pub struct TkaInitBeginRequest {
47    /// Client capability version (serializes as `Version`).
48    pub version: CapabilityVersion,
49    /// This node's public key (`nodekey:`+hex).
50    pub node_key: NodePublicKey,
51    /// The proposed genesis AUM, raw CBOR bytes (Go `tkatype.MarshaledAUM`), base64 on the wire.
52    #[serde(rename = "GenesisAUM", with = "marshaled_bytes")]
53    pub genesis_aum: Vec<u8>,
54}
55
56/// One entry in [`TkaInitBeginResponse::need_signatures`] (Go `tailcfg.TKASignInfo`): a node control
57/// needs a fresh network-lock signature for before the lock can be finalized.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "PascalCase")]
60pub struct TkaSignInfo {
61    /// The node's stable id (Go `NodeID`, an `int64`) — a plain JSON number here (it is a field, not
62    /// a map key).
63    #[serde(rename = "NodeID")]
64    pub node_id: i64,
65    /// The node's public key (`nodekey:`+hex).
66    pub node_public: NodePublicKey,
67    /// The node's rotation public key — raw ed25519 public-key bytes (Go `[]byte`), base64 on the
68    /// wire. Empty/absent when the node has none.
69    #[serde(with = "marshaled_bytes", default)]
70    pub rotation_pubkey: Vec<u8>,
71}
72
73/// Response to `GET /machine/tka/init/begin` (Go `tailcfg.TKAInitBeginResponse`): the set of nodes
74/// that must be (re)signed under the proposed lock before `init/finish` will be accepted.
75#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "PascalCase")]
77pub struct TkaInitBeginResponse {
78    /// The nodes needing signatures. Empty when none are required.
79    #[serde(default)]
80    pub need_signatures: Vec<TkaSignInfo>,
81}
82
83/// Request body for `GET /machine/tka/init/finish` (Go `tailcfg.TKAInitFinishRequest`): the
84/// per-node signatures produced for the nodes named in the begin response, finalizing the lock.
85#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "PascalCase")]
87pub struct TkaInitFinishRequest {
88    /// Client capability version.
89    pub version: CapabilityVersion,
90    /// This node's public key (`nodekey:`+hex).
91    pub node_key: NodePublicKey,
92    /// The signatures, keyed by `NodeID`. Go's `map[NodeID]MarshaledSignature` JSON-encodes as an
93    /// object with **decimal-string keys** (the `int64` `NodeID`) → base64 NKS bytes; modeled as a
94    /// `BTreeMap<String, Vec<u8>>` so the wire object round-trips byte-for-byte (deterministic order).
95    #[serde(with = "node_id_keyed_sigs")]
96    pub signatures: BTreeMap<String, Vec<u8>>,
97    /// The disablement secret(s) the lock should support, raw bytes (Go `[]byte`), base64 on the
98    /// wire. The only `omitempty` field — skipped entirely when empty, matching Go.
99    #[serde(
100        rename = "SupportDisablement",
101        with = "marshaled_bytes",
102        skip_serializing_if = "Vec::is_empty",
103        default
104    )]
105    pub support_disablement: Vec<u8>,
106}
107
108/// Response to `GET /machine/tka/init/finish` (Go `tailcfg.TKAInitFinishResponse`): empty
109/// (`// Nothing. (yet?)` in Go) — an empty JSON object on success.
110#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "PascalCase")]
112pub struct TkaInitFinishResponse {}
113
114/// Request body for `GET /machine/tka/sign` (Go `tailcfg.TKASubmitSignatureRequest`): submit a
115/// node-key signature (Direct or Rotation) to authorize a node under the lock.
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "PascalCase")]
118pub struct TkaSubmitSignatureRequest {
119    /// Client capability version.
120    pub version: CapabilityVersion,
121    /// The **submitter's** node key (`nodekey:`+hex) — not necessarily the node being signed (the
122    /// signed node key is embedded inside the NKS at `signature`).
123    pub node_key: NodePublicKey,
124    /// The signature: raw CBOR bytes of a `NodeKeySignature` (Go `tkatype.MarshaledSignature`),
125    /// base64 on the wire.
126    #[serde(with = "marshaled_bytes")]
127    pub signature: Vec<u8>,
128}
129
130/// Response to `GET /machine/tka/sign` (Go `tailcfg.TKASubmitSignatureResponse`): empty
131/// (`// Nothing. (yet?)`).
132#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "PascalCase")]
134pub struct TkaSubmitSignatureResponse {}
135
136/// Request body for `GET /machine/tka/disable` (Go `tailcfg.TKADisableRequest`): present the
137/// disablement secret to turn the lock off.
138#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "PascalCase")]
140pub struct TkaDisableRequest {
141    /// Client capability version.
142    pub version: CapabilityVersion,
143    /// This node's public key (`nodekey:`+hex).
144    pub node_key: NodePublicKey,
145    /// The chain head the disablement targets, base32 (std, no-pad) text form of the 32-byte AUM
146    /// hash. A plain `String` (Go carries the already-encoded text, not re-encoded).
147    pub head: String,
148    /// The disablement secret, raw bytes (Go `[]byte`), base64 on the wire.
149    #[serde(rename = "DisablementSecret", with = "marshaled_bytes")]
150    pub disablement_secret: Vec<u8>,
151}
152
153/// Response to `GET /machine/tka/disable` (Go `tailcfg.TKADisableResponse`): empty
154/// (`// Nothing. (yet?)`).
155#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "PascalCase")]
157pub struct TkaDisableResponse {}
158
159/// Serde for Go `map[NodeID]tkatype.MarshaledSignature`: a JSON object whose keys are the decimal
160/// `int64` `NodeID`s (as strings, per Go `encoding/json`) and whose values are standard-base64 of the
161/// raw CBOR NKS bytes. Bridges `BTreeMap<String, Vec<u8>>` ⇄ that object without an intermediate type.
162mod node_id_keyed_sigs {
163    use super::*;
164
165    pub fn serialize<S>(map: &BTreeMap<String, Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
166    where
167        S: serde::Serializer,
168    {
169        use serde::ser::SerializeMap;
170        let mut m = serializer.serialize_map(Some(map.len()))?;
171        for (k, v) in map {
172            m.serialize_entry(k, &STANDARD.encode(v))?;
173        }
174        m.end()
175    }
176
177    pub fn deserialize<'de, D>(deserializer: D) -> Result<BTreeMap<String, Vec<u8>>, D::Error>
178    where
179        D: serde::Deserializer<'de>,
180    {
181        // A `null` (Go nil map) deserializes to the empty map; otherwise an object of base64 strings.
182        let raw: Option<BTreeMap<String, String>> = Option::deserialize(deserializer)?;
183        let Some(raw) = raw else {
184            return Ok(BTreeMap::new());
185        };
186        raw.into_iter()
187            .map(|(k, s)| {
188                STANDARD
189                    .decode(s.as_bytes())
190                    .map(|bytes| (k, bytes))
191                    .map_err(|e| serde::de::Error::custom(e.to_string()))
192            })
193            .collect()
194    }
195}
196
197/// Serde for a single Go `[]byte`: a standard-base64 JSON string ⇄ `Vec<u8>` (Go `encoding/json`
198/// base64-encodes a `[]byte`). An absent/`null` field decodes to the empty `Vec`. The same shape as
199/// `tka_bootstrap`'s private `marshaled_bytes` and `tka_sync`'s `marshaled_aums` (per-element);
200/// duplicated here as a small self-contained module, matching this crate's per-module serde pattern.
201/// Used for `omitempty` fields too (paired with `skip_serializing_if`, which drops an empty field).
202mod marshaled_bytes {
203    use super::*;
204
205    pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
206    where
207        S: serde::Serializer,
208    {
209        serializer.serialize_str(&STANDARD.encode(bytes))
210    }
211
212    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
213    where
214        D: serde::Deserializer<'de>,
215    {
216        let s: Option<String> = Option::deserialize(deserializer)?;
217        let Some(s) = s else {
218            return Ok(Vec::new());
219        };
220        if s.is_empty() {
221            return Ok(Vec::new());
222        }
223        STANDARD
224            .decode(s.as_bytes())
225            .map_err(|e| serde::de::Error::custom(e.to_string()))
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn init_begin_request_wire_shape() {
235        let req = TkaInitBeginRequest {
236            version: CapabilityVersion::CURRENT,
237            node_key: NodePublicKey::from([7u8; 32]),
238            genesis_aum: alloc::vec![1u8, 2, 3],
239        };
240        let json = serde_json::to_value(&req).unwrap();
241        assert!(json.get("Version").is_some(), "Version present");
242        assert!(
243            json.get("NodeKey")
244                .and_then(|v| v.as_str())
245                .unwrap()
246                .starts_with("nodekey:"),
247            "NodeKey is nodekey:+hex"
248        );
249        assert_eq!(
250            json.get("GenesisAUM").and_then(|v| v.as_str()).unwrap(),
251            "AQID",
252            "GenesisAUM is base64(0x01 0x02 0x03)"
253        );
254        // Round-trips.
255        let back: TkaInitBeginRequest = serde_json::from_value(json).unwrap();
256        assert_eq!(back, req);
257    }
258
259    #[test]
260    fn init_begin_response_need_signatures() {
261        let json = r#"{"NeedSignatures":[{"NodeID":42,"NodePublic":"nodekey:0707070707070707070707070707070707070707070707070707070707070707","RotationPubkey":"AQID"}]}"#;
262        let resp: TkaInitBeginResponse = serde_json::from_str(json).unwrap();
263        assert_eq!(resp.need_signatures.len(), 1);
264        let info = &resp.need_signatures[0];
265        assert_eq!(info.node_id, 42, "NodeID is a plain number field");
266        assert_eq!(info.rotation_pubkey, alloc::vec![1u8, 2, 3]);
267        // null / absent NeedSignatures → empty.
268        let empty: TkaInitBeginResponse = serde_json::from_str("{}").unwrap();
269        assert!(empty.need_signatures.is_empty());
270    }
271
272    #[test]
273    fn init_finish_signatures_map_has_decimal_string_keys() {
274        let mut signatures = BTreeMap::new();
275        signatures.insert("42".to_string(), alloc::vec![0xffu8]);
276        signatures.insert("7".to_string(), alloc::vec![1u8, 2]);
277        let req = TkaInitFinishRequest {
278            version: CapabilityVersion::CURRENT,
279            node_key: NodePublicKey::from([3u8; 32]),
280            signatures,
281            support_disablement: Vec::new(),
282        };
283        let json = serde_json::to_value(&req).unwrap();
284        let sigs = json.get("Signatures").and_then(|v| v.as_object()).unwrap();
285        // Keys are the decimal NodeIDs as strings; values base64.
286        assert_eq!(sigs.get("42").and_then(|v| v.as_str()).unwrap(), "/w==");
287        assert_eq!(sigs.get("7").and_then(|v| v.as_str()).unwrap(), "AQI=");
288        // SupportDisablement is omitted when empty (Go omitempty).
289        assert!(
290            json.get("SupportDisablement").is_none(),
291            "empty SupportDisablement must be omitted"
292        );
293        let back: TkaInitFinishRequest = serde_json::from_value(json).unwrap();
294        assert_eq!(back, req);
295    }
296
297    #[test]
298    fn init_finish_support_disablement_present_when_set() {
299        let req = TkaInitFinishRequest {
300            version: CapabilityVersion::CURRENT,
301            node_key: NodePublicKey::from([3u8; 32]),
302            signatures: BTreeMap::new(),
303            support_disablement: alloc::vec![9u8, 9, 9],
304        };
305        let json = serde_json::to_value(&req).unwrap();
306        assert_eq!(
307            json.get("SupportDisablement")
308                .and_then(|v| v.as_str())
309                .unwrap(),
310            "CQkJ",
311            "SupportDisablement present + base64 when non-empty"
312        );
313        // An empty Signatures map serializes as an empty object and round-trips.
314        let back: TkaInitFinishRequest = serde_json::from_value(json).unwrap();
315        assert_eq!(back, req);
316    }
317
318    #[test]
319    fn init_finish_response_is_empty_object() {
320        let resp: TkaInitFinishResponse = serde_json::from_str("{}").unwrap();
321        assert_eq!(resp, TkaInitFinishResponse {});
322        assert_eq!(serde_json::to_string(&resp).unwrap(), "{}");
323    }
324
325    #[test]
326    fn submit_signature_request_roundtrips() {
327        let req = TkaSubmitSignatureRequest {
328            version: CapabilityVersion::CURRENT,
329            node_key: NodePublicKey::from([5u8; 32]),
330            signature: alloc::vec![1u8, 2, 3, 4],
331        };
332        let json = serde_json::to_value(&req).unwrap();
333        assert_eq!(
334            json.get("Signature").and_then(|v| v.as_str()).unwrap(),
335            "AQIDBA==",
336            "Signature is base64(CBOR NKS bytes)"
337        );
338        let back: TkaSubmitSignatureRequest = serde_json::from_value(json).unwrap();
339        assert_eq!(back, req);
340    }
341
342    #[test]
343    fn disable_request_head_is_plain_string_secret_is_base64() {
344        let req = TkaDisableRequest {
345            version: CapabilityVersion::CURRENT,
346            node_key: NodePublicKey::from([1u8; 32]),
347            head: "AEBAGBAF".to_string(),
348            disablement_secret: alloc::vec![0xde, 0xad],
349        };
350        let json = serde_json::to_value(&req).unwrap();
351        assert_eq!(
352            json.get("Head").and_then(|v| v.as_str()).unwrap(),
353            "AEBAGBAF",
354            "Head is the base32 text carried verbatim as a plain string"
355        );
356        assert_eq!(
357            json.get("DisablementSecret")
358                .and_then(|v| v.as_str())
359                .unwrap(),
360            "3q0=",
361            "DisablementSecret is base64(0xde 0xad)"
362        );
363        let back: TkaDisableRequest = serde_json::from_value(json).unwrap();
364        assert_eq!(back, req);
365    }
366
367    #[test]
368    fn empty_responses_serialize_as_empty_objects() {
369        assert_eq!(
370            serde_json::to_string(&TkaSubmitSignatureResponse {}).unwrap(),
371            "{}"
372        );
373        assert_eq!(serde_json::to_string(&TkaDisableResponse {}).unwrap(), "{}");
374    }
375}