Skip to main content

aex_core/
wire.rs

1//! On-the-wire formats shared between SDKs and the control plane.
2//!
3//! This module intentionally defines plain byte formats rather than JSON
4//! envelopes. Canonical byte sequences are the source of truth for what
5//! gets signed — any framing (JSON, protobuf, HTTP headers) is a transport
6//! concern and must not alter the signed bytes.
7
8use crate::{Error, Result};
9
10/// Current wire protocol version. Bumped only when the canonical byte
11/// sequence of any message format below changes. Old versions must continue
12/// to verify for audit replay.
13pub const PROTOCOL_VERSION: &str = "v1";
14
15/// Maximum acceptable clock skew between client and server, in seconds.
16/// Messages older/newer than this are rejected to limit replay windows.
17pub const MAX_CLOCK_SKEW_SECS: i64 = 300;
18
19/// Check if `issued_at` is within the allowed skew relative to `now`.
20/// Overflow-safe: a malicious client sending `i64::MIN` or `i64::MAX`
21/// cannot panic the server (release-mode wraparound would previously
22/// silently accept those values; debug-mode would panic).
23///
24/// Returns `true` if the message is fresh enough.
25pub fn is_within_clock_skew(now_unix: i64, issued_at_unix: i64) -> bool {
26    let diff = (now_unix as i128).saturating_sub(issued_at_unix as i128);
27    diff.unsigned_abs() <= MAX_CLOCK_SKEW_SECS as u128
28}
29
30/// Minimum nonce length (hex chars). 32 chars = 128 bits of entropy.
31pub const MIN_NONCE_LEN: usize = 32;
32
33/// Maximum nonce length (hex chars). Prevents pathological inputs.
34pub const MAX_NONCE_LEN: usize = 128;
35
36/// Produce the canonical bytes that a client signs to prove possession of
37/// the private key matching `public_key_hex` when registering an agent.
38///
39/// Format (line-based, LF terminator on each line, no trailing LF on the
40/// last line):
41///
42/// ```text
43/// spize-register:v1
44/// pub={public_key_hex}
45/// org={org}
46/// name={name}
47/// nonce={nonce}
48/// ts={issued_at_unix}
49/// ```
50///
51/// All inputs must be ASCII. The function validates inputs and returns an
52/// error if any field contains characters that could allow canonicalization
53/// ambiguity (newlines, NULs, non-ASCII).
54pub fn registration_challenge_bytes(
55    public_key_hex: &str,
56    org: &str,
57    name: &str,
58    nonce: &str,
59    issued_at_unix: i64,
60) -> Result<Vec<u8>> {
61    validate_ascii_line(public_key_hex, "public_key_hex")?;
62    validate_ascii_line(org, "org")?;
63    validate_ascii_line(name, "name")?;
64    validate_nonce(nonce)?;
65
66    let msg = format!(
67        "spize-register:{version}\npub={pub}\norg={org}\nname={name}\nnonce={nonce}\nts={ts}",
68        version = PROTOCOL_VERSION,
69        pub = public_key_hex,
70        org = org,
71        name = name,
72        nonce = nonce,
73        ts = issued_at_unix,
74    );
75    Ok(msg.into_bytes())
76}
77
78/// Ensure a string is safe to embed in a single-line canonical field.
79fn validate_ascii_line(s: &str, field: &str) -> Result<()> {
80    if s.is_empty() {
81        return Err(Error::Internal(format!("{} is empty", field)));
82    }
83    for (i, c) in s.chars().enumerate() {
84        if !c.is_ascii() || c == '\n' || c == '\r' || c == '\0' {
85            return Err(Error::Internal(format!(
86                "{} has invalid char at {}: {:?}",
87                field, i, c
88            )));
89        }
90    }
91    Ok(())
92}
93
94/// Allow-empty variant — used for optional fields (filename, declared_mime).
95fn validate_ascii_line_opt(s: &str, field: &str) -> Result<()> {
96    if s.is_empty() {
97        return Ok(());
98    }
99    validate_ascii_line(s, field)
100}
101
102/// Canonical bytes signed by the **sender** when initiating a transfer.
103///
104/// Format:
105/// ```text
106/// spize-transfer-intent:v1
107/// sender={sender_agent_id}
108/// recipient={recipient}
109/// size={size_bytes}
110/// mime={declared_mime_or_empty}
111/// filename={filename_or_empty}
112/// nonce={nonce}
113/// ts={issued_at_unix}
114/// ```
115pub fn transfer_intent_bytes(
116    sender_agent_id: &str,
117    recipient: &str,
118    size_bytes: u64,
119    declared_mime: &str,
120    filename: &str,
121    nonce: &str,
122    issued_at_unix: i64,
123) -> Result<Vec<u8>> {
124    validate_ascii_line(sender_agent_id, "sender_agent_id")?;
125    validate_ascii_line(recipient, "recipient")?;
126    validate_ascii_line_opt(declared_mime, "declared_mime")?;
127    validate_ascii_line_opt(filename, "filename")?;
128    validate_nonce(nonce)?;
129
130    let msg = format!(
131        "spize-transfer-intent:{version}\nsender={sender}\nrecipient={recipient}\nsize={size}\nmime={mime}\nfilename={filename}\nnonce={nonce}\nts={ts}",
132        version = PROTOCOL_VERSION,
133        sender = sender_agent_id,
134        recipient = recipient,
135        size = size_bytes,
136        mime = declared_mime,
137        filename = filename,
138        nonce = nonce,
139        ts = issued_at_unix,
140    );
141    Ok(msg.into_bytes())
142}
143
144/// Canonical bytes signed by the **control plane** when issuing a data-
145/// plane ticket. A ticket is a short-lived capability that authorises
146/// the holder to fetch blob bytes from a data-plane server directly,
147/// without the control plane proxying the stream.
148///
149/// Data-plane servers verify the ticket signature against the control
150/// plane's published public key (fetched from `/.well-known/spize-cp.pub`
151/// or out-of-band) before streaming bytes.
152///
153/// ```text
154/// spize-data-ticket:v1
155/// transfer={transfer_id}
156/// recipient={recipient_agent_id}
157/// data_plane={data_plane_url}
158/// expires={expires_unix}
159/// nonce={nonce}
160/// ```
161pub fn data_ticket_bytes(
162    transfer_id: &str,
163    recipient_agent_id: &str,
164    data_plane_url: &str,
165    expires_unix: i64,
166    nonce: &str,
167) -> Result<Vec<u8>> {
168    validate_ascii_line(transfer_id, "transfer_id")?;
169    validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
170    validate_ascii_line(data_plane_url, "data_plane_url")?;
171    validate_nonce(nonce)?;
172
173    let msg = format!(
174        "spize-data-ticket:{version}\ntransfer={tx}\nrecipient={rec}\ndata_plane={dp}\nexpires={exp}\nnonce={nonce}",
175        version = PROTOCOL_VERSION,
176        tx = transfer_id,
177        rec = recipient_agent_id,
178        dp = data_plane_url,
179        exp = expires_unix,
180        nonce = nonce,
181    );
182    Ok(msg.into_bytes())
183}
184
185/// Canonical bytes signed by the **recipient** when requesting the blob or
186/// acknowledging delivery. Binds the recipient's identity to the specific
187/// transfer_id and a fresh nonce to prevent replay.
188pub fn transfer_receipt_bytes(
189    recipient_agent_id: &str,
190    transfer_id: &str,
191    action: &str,
192    nonce: &str,
193    issued_at_unix: i64,
194) -> Result<Vec<u8>> {
195    validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
196    validate_ascii_line(transfer_id, "transfer_id")?;
197    validate_ascii_line(action, "action")?;
198    validate_nonce(nonce)?;
199
200    if !matches!(action, "download" | "ack" | "inbox" | "request_ticket") {
201        return Err(Error::Internal(format!(
202            "action must be 'download', 'ack', 'inbox' or 'request_ticket', got {}",
203            action
204        )));
205    }
206
207    let msg = format!(
208        "spize-transfer-receipt:{version}\nrecipient={rec}\ntransfer={tx}\naction={act}\nnonce={nonce}\nts={ts}",
209        version = PROTOCOL_VERSION,
210        rec = recipient_agent_id,
211        tx = transfer_id,
212        act = action,
213        nonce = nonce,
214        ts = issued_at_unix,
215    );
216    Ok(msg.into_bytes())
217}
218
219fn validate_nonce(nonce: &str) -> Result<()> {
220    if nonce.len() < MIN_NONCE_LEN || nonce.len() > MAX_NONCE_LEN {
221        return Err(Error::Internal(format!(
222            "nonce length {} outside [{}, {}]",
223            nonce.len(),
224            MIN_NONCE_LEN,
225            MAX_NONCE_LEN
226        )));
227    }
228    if !nonce.chars().all(|c| c.is_ascii_hexdigit()) {
229        return Err(Error::Internal("nonce must be hex".into()));
230    }
231    Ok(())
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn canonical_bytes_stable() {
240        let bytes = registration_challenge_bytes(
241            "aabbcc",
242            "acme",
243            "alice",
244            "0123456789abcdef0123456789abcdef",
245            1_700_000_000,
246        )
247        .unwrap();
248        let expected = "spize-register:v1\npub=aabbcc\norg=acme\nname=alice\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
249        assert_eq!(bytes, expected.as_bytes());
250    }
251
252    #[test]
253    fn different_inputs_different_bytes() {
254        let a = registration_challenge_bytes(
255            "aa", "acme", "alice", "0123456789abcdef0123456789abcdef", 100,
256        )
257        .unwrap();
258        let b = registration_challenge_bytes(
259            "aa", "acme", "alice", "0123456789abcdef0123456789abcdef", 101,
260        )
261        .unwrap();
262        assert_ne!(a, b);
263    }
264
265    #[test]
266    fn newline_in_field_rejected() {
267        let err = registration_challenge_bytes(
268            "aa",
269            "ac\nme",
270            "alice",
271            "0123456789abcdef0123456789abcdef",
272            100,
273        )
274        .unwrap_err();
275        assert!(matches!(err, Error::Internal(_)));
276    }
277
278    #[test]
279    fn non_ascii_field_rejected() {
280        let err = registration_challenge_bytes(
281            "aa",
282            "acmè",
283            "alice",
284            "0123456789abcdef0123456789abcdef",
285            100,
286        )
287        .unwrap_err();
288        assert!(matches!(err, Error::Internal(_)));
289    }
290
291    #[test]
292    fn short_nonce_rejected() {
293        let err = registration_challenge_bytes("aa", "acme", "alice", "deadbeef", 100)
294            .unwrap_err();
295        assert!(matches!(err, Error::Internal(_)));
296    }
297
298    #[test]
299    fn non_hex_nonce_rejected() {
300        let err = registration_challenge_bytes(
301            "aa",
302            "acme",
303            "alice",
304            "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
305            100,
306        )
307        .unwrap_err();
308        assert!(matches!(err, Error::Internal(_)));
309    }
310
311    #[test]
312    fn empty_pub_rejected() {
313        let err = registration_challenge_bytes(
314            "",
315            "acme",
316            "alice",
317            "0123456789abcdef0123456789abcdef",
318            100,
319        )
320        .unwrap_err();
321        assert!(matches!(err, Error::Internal(_)));
322    }
323
324    #[test]
325    fn transfer_intent_stable() {
326        let bytes = transfer_intent_bytes(
327            "spize:acme/alice:aabbcc",
328            "spize:acme/bob:ddeeff",
329            12345,
330            "application/pdf",
331            "invoice.pdf",
332            "0123456789abcdef0123456789abcdef",
333            1_700_000_000,
334        )
335        .unwrap();
336        let expected = "spize-transfer-intent:v1\nsender=spize:acme/alice:aabbcc\nrecipient=spize:acme/bob:ddeeff\nsize=12345\nmime=application/pdf\nfilename=invoice.pdf\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
337        assert_eq!(bytes, expected.as_bytes());
338    }
339
340    #[test]
341    fn transfer_intent_empty_optionals() {
342        let bytes = transfer_intent_bytes(
343            "spize:acme/alice:aabbcc",
344            "bob@example.com",
345            100,
346            "",
347            "",
348            "0123456789abcdef0123456789abcdef",
349            1_700_000_000,
350        )
351        .unwrap();
352        let s = std::str::from_utf8(&bytes).unwrap();
353        assert!(s.contains("mime=\n"));
354        assert!(s.contains("filename=\n"));
355    }
356
357    #[test]
358    fn transfer_receipt_stable() {
359        let bytes = transfer_receipt_bytes(
360            "spize:acme/bob:ddeeff",
361            "tx_abc123",
362            "ack",
363            "0123456789abcdef0123456789abcdef",
364            1_700_000_000,
365        )
366        .unwrap();
367        let expected = "spize-transfer-receipt:v1\nrecipient=spize:acme/bob:ddeeff\ntransfer=tx_abc123\naction=ack\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
368        assert_eq!(bytes, expected.as_bytes());
369    }
370
371    #[test]
372    fn clock_skew_within_window_accepted() {
373        let now = 1_700_000_000;
374        assert!(is_within_clock_skew(now, now));
375        assert!(is_within_clock_skew(now, now - 300));
376        assert!(is_within_clock_skew(now, now + 300));
377    }
378
379    #[test]
380    fn clock_skew_outside_window_rejected() {
381        let now = 1_700_000_000;
382        assert!(!is_within_clock_skew(now, now - 301));
383        assert!(!is_within_clock_skew(now, now + 301));
384    }
385
386    #[test]
387    fn clock_skew_extreme_inputs_do_not_panic() {
388        // Pre-fix: `(now - issued_at).abs()` overflows on these in debug.
389        let now = 1_700_000_000;
390        assert!(!is_within_clock_skew(now, i64::MIN));
391        assert!(!is_within_clock_skew(now, i64::MAX));
392        assert!(!is_within_clock_skew(i64::MAX, i64::MIN));
393    }
394
395    #[test]
396    fn transfer_receipt_rejects_bad_action() {
397        let err = transfer_receipt_bytes(
398            "spize:acme/bob:ddeeff",
399            "tx_abc",
400            "overwrite",
401            "0123456789abcdef0123456789abcdef",
402            1,
403        )
404        .unwrap_err();
405        assert!(matches!(err, Error::Internal(_)));
406    }
407
408    #[test]
409    fn data_ticket_stable() {
410        let bytes = data_ticket_bytes(
411            "tx_abc123",
412            "spize:acme/bob:ddeeff",
413            "https://data.spize.ai",
414            1_700_000_100,
415            "0123456789abcdef0123456789abcdef",
416        )
417        .unwrap();
418        let expected = "spize-data-ticket:v1\ntransfer=tx_abc123\nrecipient=spize:acme/bob:ddeeff\ndata_plane=https://data.spize.ai\nexpires=1700000100\nnonce=0123456789abcdef0123456789abcdef";
419        assert_eq!(bytes, expected.as_bytes());
420    }
421
422    #[test]
423    fn data_ticket_rejects_newline_url() {
424        let err = data_ticket_bytes(
425            "tx_abc",
426            "spize:acme/bob:ddeeff",
427            "https://evil.test\nspoof",
428            1,
429            "0123456789abcdef0123456789abcdef",
430        )
431        .unwrap_err();
432        assert!(matches!(err, Error::Internal(_)));
433    }
434}