truffle_core/session/hello.rs
1//! Hello envelope — Layer 5 identity handshake (RFC 017 §8).
2//!
3//! When two truffle nodes establish a WebSocket link, each side sends a
4//! [`HelloEnvelope`] as the very first message over the socket and expects
5//! one back before any application traffic flows. The envelope advertises
6//! the sender's `app_id`, stable `device_id`, human-readable `device_name`,
7//! operating system, and (as an escape hatch) its Tailscale stable ID.
8//!
9//! # Version
10//!
11//! `HelloEnvelope::version` starts at `2`. Version 1 is implicit in
12//! pre-RFC-017 code and is explicitly rejected — RFC 017 is a clean break
13//! and older peers cannot talk to newer ones.
14//!
15//! # Deviation from RFC §8
16//!
17//! RFC 017 §8 lists four fields inside the `identity` block: `app_id`,
18//! `device_id`, `device_name`, `os`. This implementation adds a fifth
19//! field, `tailscale_id`, because the session layer still routes
20//! WebSocket connections by Tailscale stable ID (the primary key of the
21//! session's peer map). Carrying the remote's Tailscale ID in the hello
22//! lets the session correlate the freshly-accepted WS link with the
23//! Layer 3 peer entry that already exists. A future phase can remove
24//! this field once the session layer re-keys its connection map by
25//! `device_id`.
26
27use serde::{Deserialize, Serialize};
28
29// ---------------------------------------------------------------------------
30// WebSocket close codes (RFC 017 §8)
31// ---------------------------------------------------------------------------
32
33/// Remote peer's `app_id` did not match ours. The link is closed immediately
34/// with this code so the remote sees a specific reason.
35pub const CLOSE_APP_MISMATCH: u16 = 4001;
36
37/// Remote peer sent a malformed hello envelope, or no hello at all within the
38/// timeout.
39pub const CLOSE_HELLO_PROTOCOL: u16 = 4002;
40
41/// The hello envelope claimed a `tailscale_id` that does not match the
42/// Tailscale-authenticated identity (WhoIs) of the underlying connection.
43pub const CLOSE_IDENTITY_MISMATCH: u16 = 4003;
44
45/// Timeout applied to the first hello envelope read.
46pub const HELLO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
47
48// ---------------------------------------------------------------------------
49// HelloEnvelope
50// ---------------------------------------------------------------------------
51
52/// Identity block sent once in each direction when a WebSocket link comes up
53/// (RFC 017 §8). Version 2 is the first with a structured `identity` field;
54/// version 1 is implicit in pre-RFC-017 code.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct HelloEnvelope {
57 /// Wire tag discriminating this frame from other possible envelopes.
58 pub kind: HelloKind,
59 /// Version of the hello envelope schema. Must be >= 2.
60 pub version: u32,
61 /// The sender's identity block.
62 pub identity: PeerIdentity,
63}
64
65/// Tag identifying a [`HelloEnvelope`] on the wire.
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum HelloKind {
69 /// The sole variant — a hello envelope.
70 Hello,
71}
72
73/// Peer identity metadata advertised by a truffle node in its hello envelope.
74///
75/// The primary key for peer identity is `device_id`, which is stable across
76/// Tailscale re-auths and ephemeral-mode rotations. `tailscale_id` is an
77/// escape hatch kept so the session layer can correlate the hello with its
78/// Layer 3 peer entry — see the module-level note on the RFC §8 deviation.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct PeerIdentity {
81 /// The application namespace. Peers with different `app_id` close the
82 /// connection immediately (RFC §8, close code 4001).
83 pub app_id: String,
84 /// Stable per-device ULID.
85 pub device_id: String,
86 /// Human-readable device name (original form, NOT the slug).
87 pub device_name: String,
88 /// Operating system identifier (e.g., "darwin", "linux", "windows").
89 pub os: String,
90 /// Tailscale stable node ID — escape hatch used by the session layer to
91 /// cross-reference the hello with its Layer 3 peer registry.
92 #[serde(default)]
93 pub tailscale_id: String,
94}
95
96impl HelloEnvelope {
97 /// The hello envelope schema version this build emits.
98 pub const CURRENT_VERSION: u32 = 2;
99 /// The minimum hello envelope version this build will accept from a peer.
100 pub const MIN_SUPPORTED_VERSION: u32 = 2;
101
102 /// Construct a new hello envelope at [`Self::CURRENT_VERSION`] carrying
103 /// `identity`.
104 pub fn new(identity: PeerIdentity) -> Self {
105 Self {
106 kind: HelloKind::Hello,
107 version: Self::CURRENT_VERSION,
108 identity,
109 }
110 }
111}
112
113// ---------------------------------------------------------------------------
114// Tests
115// ---------------------------------------------------------------------------
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn hello_envelope_round_trips_json() {
123 let hello = HelloEnvelope::new(PeerIdentity {
124 app_id: "playground".into(),
125 device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
126 device_name: "Alice's MacBook".into(),
127 os: "darwin".into(),
128 tailscale_id: "node-abc".into(),
129 });
130
131 let json = serde_json::to_string(&hello).unwrap();
132 let parsed: HelloEnvelope = serde_json::from_str(&json).unwrap();
133 assert_eq!(parsed.version, HelloEnvelope::CURRENT_VERSION);
134 assert_eq!(parsed.identity.app_id, "playground");
135 assert_eq!(parsed.identity.device_id, "01J4K9M2Z8AB3RNYQPW6H5TC0X");
136 assert_eq!(parsed.identity.device_name, "Alice's MacBook");
137 assert_eq!(parsed.identity.os, "darwin");
138 assert_eq!(parsed.identity.tailscale_id, "node-abc");
139 }
140
141 #[test]
142 fn hello_envelope_json_layout_matches_rfc_shape() {
143 let hello = HelloEnvelope::new(PeerIdentity {
144 app_id: "chat".into(),
145 device_id: "01HZZZZZZZZZZZZZZZZZZZZZZZ".into(),
146 device_name: "laptop".into(),
147 os: "linux".into(),
148 tailscale_id: "tsnode-1".into(),
149 });
150 let value: serde_json::Value = serde_json::to_value(&hello).unwrap();
151 assert_eq!(value["kind"], "hello");
152 assert_eq!(value["version"], 2);
153 assert_eq!(value["identity"]["app_id"], "chat");
154 assert_eq!(value["identity"]["device_id"], "01HZZZZZZZZZZZZZZZZZZZZZZZ");
155 assert_eq!(value["identity"]["device_name"], "laptop");
156 assert_eq!(value["identity"]["os"], "linux");
157 }
158
159 #[test]
160 fn hello_envelope_rejects_unknown_kind() {
161 let raw = r#"{"kind":"not_hello","version":2,"identity":{"app_id":"a","device_id":"x","device_name":"y","os":"darwin","tailscale_id":"z"}}"#;
162 let parsed: Result<HelloEnvelope, _> = serde_json::from_str(raw);
163 assert!(
164 parsed.is_err(),
165 "unknown kind should fail to deserialize as HelloEnvelope"
166 );
167 }
168}