1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
//! The pairing ticket and its parse errors.
//!
//! Pure: this module never touches the network. It owns what a ticket
//! *contains* — the fields, their widths, and how they are laid out as
//! bytes. How those bytes are spelled as a string, and the order a parser
//! checks things in, is [`crate::ticket_string`]'s. What is done with the
//! endpoint a ticket names belongs to the transport, and is neither's.
//!
//! The normative contract is `docs/ticket-format-v0.md`, which ships in
//! this crate's published tarball. Everything here implements that page;
//! where the two could disagree, the page wins, and
//! `scripts/ticket_vectors.py --check` plus the hard-coded vectors in
//! `ticket_tests.rs` are what keep them from drifting.
use std::fmt;
use crate::crc32c::crc32c;
use crate::ticket_addr::TicketAddr;
/// The only format version this build speaks.
pub(crate) const VERSION_V0: u8 = 0x00;
/// `openai-compatible`, the only backend hint assigned in v0.
const BACKEND_OPENAI_COMPATIBLE: u8 = 0x00;
// Field widths. Every bound below is a sum of these rather than a literal,
// so the arithmetic is checkable by reading it and a field that changes
// width cannot leave a stale constant behind.
const VERSION_LEN: usize = 1;
const ENDPOINT_ID_LEN: usize = 32;
const ADDR_COUNT_LEN: usize = 1;
const BACKEND_LEN: usize = 1;
pub(crate) const CRC_LEN: usize = 4;
/// The smallest possible v0 ticket: the fixed fields with no addresses.
pub(crate) const MIN_V0: usize =
VERSION_LEN + ENDPOINT_ID_LEN + ADDR_COUNT_LEN + BACKEND_LEN + CRC_LEN;
/// The decoded-size cap, a denial-of-service guard independent of version.
pub(crate) const MAX_TICKET_BYTES: usize = 1024;
/// What kind of server sits behind a ticket's endpoint.
///
/// A hint, not a contract: v0 assigns one value and a parser must accept
/// any other rather than failing, so that a newer serve side stays pairable
/// from an older client.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum BackendHint {
/// The only value v0 assigns.
OpenAiCompatible,
/// A value assigned after this build was written. Carried so the byte
/// survives a round trip; v0 defines no accessor, so nothing consumes it.
Unknown(u8),
}
impl BackendHint {
/// The wire byte.
const fn as_byte(self) -> u8 {
match self {
Self::OpenAiCompatible => BACKEND_OPENAI_COMPATIBLE,
Self::Unknown(b) => b,
}
}
/// Never fails: an unrecognised hint is carried, not refused.
const fn from_byte(b: u8) -> Self {
if b == BACKEND_OPENAI_COMPATIBLE {
Self::OpenAiCompatible
} else {
Self::Unknown(b)
}
}
}
/// A pairing ticket: how one machine finds and authenticates another's
/// listener.
///
/// Base32 on the wire so it survives terminals, being read aloud, and —
/// with the case rule the README's format section records — QR codes.
///
/// Contains the serve side's endpoint identity (endpoint id plus a set of
/// transport addresses) and a backend-kind hint — and deliberately *not*
/// the bearer token, which travels separately so that a leaked ticket
/// alone cannot make a request. The format is versioned; the byte-level
/// contract, test vectors and refusal taxonomy included, is
/// `docs/ticket-format-v0.md`.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct Ticket {
// Field layout is private; docs/ticket-format-v0.md is the public
// contract.
endpoint_id: [u8; ENDPOINT_ID_LEN],
/// Canonical at all times: sorted by encoded bytes, no duplicates, and
/// short enough that the whole ticket fits the cap. [`Ticket::new`]
/// is the only thing that builds this, and it establishes all three —
/// which is what lets [`Ticket::encode`] be infallible.
addrs: Vec<TicketAddr>,
backend: BackendHint,
}
impl Ticket {
/// Build a ticket, canonicalizing and size-bounding its addresses.
///
/// Infallible by construction. The spec makes staying under the cap an
/// *encoder* obligation — the field widths admit far more than any
/// parser accepts — and says an encoder drops lowest-priority addresses
/// rather than minting a ticket nobody can read. That is what this does,
/// and it cannot run out of things to drop: an address-free ticket is
/// `MIN_V0` bytes, far inside the cap.
///
/// "Lowest priority" falls out of the canonical order rather than being
/// a second rule to remember. Sorting by encoded bytes groups relays
/// (tag `0x00`) before IPv4 (`0x01`) before IPv6 (`0x02`), so dropping
/// from the end sheds direct addresses first and keeps relays longest —
/// which is the right way round: a direct address is an optimization,
/// while the relay is what connects at all under a NAT that refuses to
/// hole-punch.
pub(crate) fn new(
endpoint_id: [u8; ENDPOINT_ID_LEN],
addrs: Vec<TicketAddr>,
backend: BackendHint,
) -> Self {
let mut encoded: Vec<(Vec<u8>, TicketAddr)> = addrs
.into_iter()
// An address too long to frame is dropped rather than fatal: it
// is unusable either way, and a ticket without it still pairs.
.filter_map(|a| a.encoded().map(|bytes| (bytes, a)))
.collect();
encoded.sort_by(|a, b| a.0.cmp(&b.0));
encoded.dedup_by(|a, b| a.0 == b.0);
// `addr_count` is a u8, so the count is bounded before the bytes are.
encoded.truncate(u8::MAX as usize);
let mut total = MIN_V0 + encoded.iter().map(|(b, _)| b.len()).sum::<usize>();
while total > MAX_TICKET_BYTES {
let (bytes, _) = encoded.pop().expect("MIN_V0 alone is inside the cap");
total -= bytes.len();
}
Self {
endpoint_id,
addrs: encoded.into_iter().map(|(_, a)| a).collect(),
backend,
}
}
/// The serve side's endpoint identity, as raw bytes.
///
/// `pub(crate)`: the transport needs it to dial, and nobody outside
/// this crate has anything to do with a raw key.
pub(crate) const fn endpoint_id(&self) -> &[u8; ENDPOINT_ID_LEN] {
&self.endpoint_id
}
/// The transport addresses this ticket carries, in canonical order.
pub(crate) fn addrs(&self) -> &[TicketAddr] {
&self.addrs
}
/// Short fingerprint of the serve side's identity: enough to compare
/// two tickets by eye, or to name one in a log line, without putting
/// ninety-six characters there. Never the full key.
///
/// This is what [`Debug`](fmt::Debug) renders instead of the ticket
/// string, which is the reason it is not merely a convenience — see
/// that impl for why a ticket must not land in a panic message whole.
pub fn fingerprint(&self) -> String {
crate::fingerprint::of(&self.endpoint_id)
}
/// The ticket's bytes, per the format spec.
///
/// Infallible: [`Ticket::new`] is the only constructor and it
/// establishes the invariants — canonical addresses, inside the cap —
/// that would otherwise make this fallible. A ticket that exists is a
/// ticket that encodes.
pub(crate) fn encode(&self) -> Vec<u8> {
let mut body = Vec::with_capacity(MIN_V0);
body.push(VERSION_V0);
body.extend_from_slice(&self.endpoint_id);
body.push(u8::try_from(self.addrs.len()).expect("new() bounds the count"));
for addr in &self.addrs {
body.extend_from_slice(&addr.encoded().expect("new() dropped unframeable addresses"));
}
body.push(self.backend.as_byte());
let crc = crc32c(&body);
body.extend_from_slice(&crc.to_be_bytes());
debug_assert!(body.len() <= MAX_TICKET_BYTES, "new() bounds the total");
body
}
/// Parse verified ticket bytes into a ticket.
///
/// Assumes the caller has already checked everything the format spec
/// puts before the structure — version, minimum length, checksum — and
/// enforces what it puts after: exact consumption, and no bytes left
/// between the structure's end and the checksum.
pub(crate) fn decode(bytes: &[u8]) -> Result<Self, TicketParseError> {
let body = &bytes[..bytes.len() - CRC_LEN];
let mut pos = VERSION_LEN;
let endpoint_id: [u8; ENDPOINT_ID_LEN] = body
.get(pos..pos + ENDPOINT_ID_LEN)
.ok_or(TicketParseError::Malformed)?
.try_into()
.expect("slice length checked");
pos += ENDPOINT_ID_LEN;
let count = *body.get(pos).ok_or(TicketParseError::Malformed)?;
pos += ADDR_COUNT_LEN;
let mut addrs = Vec::with_capacity(count as usize);
for _ in 0..count {
// `None` is a tag this version does not know: already stepped
// over, and deliberately absent from the result. The parsed list
// is therefore allowed to be shorter than `count`.
if let Some(addr) = TicketAddr::read(body, &mut pos)? {
addrs.push(addr);
}
}
let backend = BackendHint::from_byte(*body.get(pos).ok_or(TicketParseError::Malformed)?);
pos += BACKEND_LEN;
if pos != body.len() {
return Err(TicketParseError::Malformed);
}
// Through `new`, never a struct literal. The spec says a decoder
// accepts addresses in any order and that "a duplicated address
// collapses — the parsed result is a set", and that an encoder emits
// them sorted "so equal tickets compare equal as strings".
// `Display` is an encoder, so building the fields directly here made
// parse-then-print reproduce whatever order arrived, and made two
// tickets naming one pairing compare unequal.
//
// `new` also collapses duplicates by encoded bytes, which is the key
// its sort already uses; the loop above did it by `PartialEq`, a
// second and subtly different rule for the same job.
Ok(Self::new(endpoint_id, addrs, backend))
}
}
impl fmt::Debug for Ticket {
// Hand-written, never derived: `Display` emits the full ticket (that
// is its job — the CLI prints it), so a derived `Debug` over the real
// fields would copy pairing credentials into every downstream panic
// message and `tracing` line. The fingerprint is the only part of a
// ticket a log should ever see.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Ticket").field(&self.fingerprint()).finish()
}
}
/// Why a ticket string failed to parse. Deliberately coarse: a ticket is
/// pasted or scanned, so the advice is one line — re-copy it, or, when
/// the format is newer than this build, upgrade.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TicketParseError {
/// Bad base32, truncated, checksum failure — any of the re-copy-it
/// failures the format spec routes here.
Malformed,
/// Parsed, but a format version this build doesn't speak.
UnsupportedVersion(u8),
}
impl fmt::Display for TicketParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed => write!(f, "ticket is malformed — re-copy it from the serve side"),
Self::UnsupportedVersion(v) => write!(f, "ticket format v{v} is newer than this build"),
}
}
}
impl std::error::Error for TicketParseError {}
#[cfg(test)]
#[path = "ticket_tests.rs"]
mod ticket_tests;