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 an agent's **outgoing** (current) key when
186/// requesting to rotate to a new public key. Part of the formal rotation
187/// protocol defined in ADR-0024.
188///
189/// The control plane re-derives these bytes and verifies the signature
190/// against the CURRENT stored public key for `agent_id`. On success it
191/// records the new key with `valid_from = now()` and closes the old
192/// key's `valid_to` window 24h in the future — during that grace period
193/// signatures from either key verify, so in-flight receipts signed by
194/// the old key keep working while new signatures use the new one.
195///
196/// The new key is declared but NOT required to co-sign: the current key
197/// authorises, and the agent is trusted to have proof-of-possession of
198/// the new key through the device-local generation path. This mirrors
199/// ADR-0024's "old key authorises, new key takes over after grace".
200///
201/// Format:
202/// ```text
203/// spize-rotate-key:v1
204/// agent={agent_id}
205/// old_pub={current_public_key_hex}
206/// new_pub={new_public_key_hex}
207/// nonce={nonce}
208/// ts={issued_at_unix}
209/// ```
210pub fn rotate_key_challenge_bytes(
211    agent_id: &str,
212    old_public_key_hex: &str,
213    new_public_key_hex: &str,
214    nonce: &str,
215    issued_at_unix: i64,
216) -> Result<Vec<u8>> {
217    validate_ascii_line(agent_id, "agent_id")?;
218    validate_ascii_line(old_public_key_hex, "old_public_key_hex")?;
219    validate_ascii_line(new_public_key_hex, "new_public_key_hex")?;
220    validate_nonce(nonce)?;
221
222    if old_public_key_hex == new_public_key_hex {
223        return Err(Error::Internal(
224            "old_public_key_hex and new_public_key_hex must differ".into(),
225        ));
226    }
227
228    let msg = format!(
229        "spize-rotate-key:{version}\nagent={agent}\nold_pub={old}\nnew_pub={new}\nnonce={nonce}\nts={ts}",
230        version = PROTOCOL_VERSION,
231        agent = agent_id,
232        old = old_public_key_hex,
233        new = new_public_key_hex,
234        nonce = nonce,
235        ts = issued_at_unix,
236    );
237    Ok(msg.into_bytes())
238}
239
240/// Canonical bytes signed by the **recipient** when requesting the blob or
241/// acknowledging delivery. Binds the recipient's identity to the specific
242/// transfer_id and a fresh nonce to prevent replay.
243pub fn transfer_receipt_bytes(
244    recipient_agent_id: &str,
245    transfer_id: &str,
246    action: &str,
247    nonce: &str,
248    issued_at_unix: i64,
249) -> Result<Vec<u8>> {
250    validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
251    validate_ascii_line(transfer_id, "transfer_id")?;
252    validate_ascii_line(action, "action")?;
253    validate_nonce(nonce)?;
254
255    if !matches!(action, "download" | "ack" | "inbox" | "request_ticket") {
256        return Err(Error::Internal(format!(
257            "action must be 'download', 'ack', 'inbox' or 'request_ticket', got {}",
258            action
259        )));
260    }
261
262    let msg = format!(
263        "spize-transfer-receipt:{version}\nrecipient={rec}\ntransfer={tx}\naction={act}\nnonce={nonce}\nts={ts}",
264        version = PROTOCOL_VERSION,
265        rec = recipient_agent_id,
266        tx = transfer_id,
267        act = action,
268        nonce = nonce,
269        ts = issued_at_unix,
270    );
271    Ok(msg.into_bytes())
272}
273
274fn validate_nonce(nonce: &str) -> Result<()> {
275    if nonce.len() < MIN_NONCE_LEN || nonce.len() > MAX_NONCE_LEN {
276        return Err(Error::Internal(format!(
277            "nonce length {} outside [{}, {}]",
278            nonce.len(),
279            MIN_NONCE_LEN,
280            MAX_NONCE_LEN
281        )));
282    }
283    if !nonce.chars().all(|c| c.is_ascii_hexdigit()) {
284        return Err(Error::Internal("nonce must be hex".into()));
285    }
286    Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn canonical_bytes_stable() {
295        let bytes = registration_challenge_bytes(
296            "aabbcc",
297            "acme",
298            "alice",
299            "0123456789abcdef0123456789abcdef",
300            1_700_000_000,
301        )
302        .unwrap();
303        let expected = "spize-register:v1\npub=aabbcc\norg=acme\nname=alice\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
304        assert_eq!(bytes, expected.as_bytes());
305    }
306
307    #[test]
308    fn different_inputs_different_bytes() {
309        let a = registration_challenge_bytes(
310            "aa",
311            "acme",
312            "alice",
313            "0123456789abcdef0123456789abcdef",
314            100,
315        )
316        .unwrap();
317        let b = registration_challenge_bytes(
318            "aa",
319            "acme",
320            "alice",
321            "0123456789abcdef0123456789abcdef",
322            101,
323        )
324        .unwrap();
325        assert_ne!(a, b);
326    }
327
328    #[test]
329    fn newline_in_field_rejected() {
330        let err = registration_challenge_bytes(
331            "aa",
332            "ac\nme",
333            "alice",
334            "0123456789abcdef0123456789abcdef",
335            100,
336        )
337        .unwrap_err();
338        assert!(matches!(err, Error::Internal(_)));
339    }
340
341    #[test]
342    fn non_ascii_field_rejected() {
343        let err = registration_challenge_bytes(
344            "aa",
345            "acmè",
346            "alice",
347            "0123456789abcdef0123456789abcdef",
348            100,
349        )
350        .unwrap_err();
351        assert!(matches!(err, Error::Internal(_)));
352    }
353
354    #[test]
355    fn short_nonce_rejected() {
356        let err = registration_challenge_bytes("aa", "acme", "alice", "deadbeef", 100).unwrap_err();
357        assert!(matches!(err, Error::Internal(_)));
358    }
359
360    #[test]
361    fn non_hex_nonce_rejected() {
362        let err = registration_challenge_bytes(
363            "aa",
364            "acme",
365            "alice",
366            "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
367            100,
368        )
369        .unwrap_err();
370        assert!(matches!(err, Error::Internal(_)));
371    }
372
373    #[test]
374    fn empty_pub_rejected() {
375        let err = registration_challenge_bytes(
376            "",
377            "acme",
378            "alice",
379            "0123456789abcdef0123456789abcdef",
380            100,
381        )
382        .unwrap_err();
383        assert!(matches!(err, Error::Internal(_)));
384    }
385
386    #[test]
387    fn transfer_intent_stable() {
388        let bytes = transfer_intent_bytes(
389            "spize:acme/alice:aabbcc",
390            "spize:acme/bob:ddeeff",
391            12345,
392            "application/pdf",
393            "invoice.pdf",
394            "0123456789abcdef0123456789abcdef",
395            1_700_000_000,
396        )
397        .unwrap();
398        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";
399        assert_eq!(bytes, expected.as_bytes());
400    }
401
402    #[test]
403    fn transfer_intent_empty_optionals() {
404        let bytes = transfer_intent_bytes(
405            "spize:acme/alice:aabbcc",
406            "bob@example.com",
407            100,
408            "",
409            "",
410            "0123456789abcdef0123456789abcdef",
411            1_700_000_000,
412        )
413        .unwrap();
414        let s = std::str::from_utf8(&bytes).unwrap();
415        assert!(s.contains("mime=\n"));
416        assert!(s.contains("filename=\n"));
417    }
418
419    #[test]
420    fn transfer_receipt_stable() {
421        let bytes = transfer_receipt_bytes(
422            "spize:acme/bob:ddeeff",
423            "tx_abc123",
424            "ack",
425            "0123456789abcdef0123456789abcdef",
426            1_700_000_000,
427        )
428        .unwrap();
429        let expected = "spize-transfer-receipt:v1\nrecipient=spize:acme/bob:ddeeff\ntransfer=tx_abc123\naction=ack\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
430        assert_eq!(bytes, expected.as_bytes());
431    }
432
433    #[test]
434    fn clock_skew_within_window_accepted() {
435        let now = 1_700_000_000;
436        assert!(is_within_clock_skew(now, now));
437        assert!(is_within_clock_skew(now, now - 300));
438        assert!(is_within_clock_skew(now, now + 300));
439    }
440
441    #[test]
442    fn clock_skew_outside_window_rejected() {
443        let now = 1_700_000_000;
444        assert!(!is_within_clock_skew(now, now - 301));
445        assert!(!is_within_clock_skew(now, now + 301));
446    }
447
448    #[test]
449    fn clock_skew_extreme_inputs_do_not_panic() {
450        // Pre-fix: `(now - issued_at).abs()` overflows on these in debug.
451        let now = 1_700_000_000;
452        assert!(!is_within_clock_skew(now, i64::MIN));
453        assert!(!is_within_clock_skew(now, i64::MAX));
454        assert!(!is_within_clock_skew(i64::MAX, i64::MIN));
455    }
456
457    #[test]
458    fn transfer_receipt_rejects_bad_action() {
459        let err = transfer_receipt_bytes(
460            "spize:acme/bob:ddeeff",
461            "tx_abc",
462            "overwrite",
463            "0123456789abcdef0123456789abcdef",
464            1,
465        )
466        .unwrap_err();
467        assert!(matches!(err, Error::Internal(_)));
468    }
469
470    #[test]
471    fn data_ticket_stable() {
472        let bytes = data_ticket_bytes(
473            "tx_abc123",
474            "spize:acme/bob:ddeeff",
475            "https://data.spize.io",
476            1_700_000_100,
477            "0123456789abcdef0123456789abcdef",
478        )
479        .unwrap();
480        let expected = "spize-data-ticket:v1\ntransfer=tx_abc123\nrecipient=spize:acme/bob:ddeeff\ndata_plane=https://data.spize.io\nexpires=1700000100\nnonce=0123456789abcdef0123456789abcdef";
481        assert_eq!(bytes, expected.as_bytes());
482    }
483
484    #[test]
485    fn rotate_key_stable() {
486        let bytes = rotate_key_challenge_bytes(
487            "spize:acme/alice:aabbcc",
488            "1111111111111111111111111111111111111111111111111111111111111111",
489            "2222222222222222222222222222222222222222222222222222222222222222",
490            "0123456789abcdef0123456789abcdef",
491            1_700_000_000,
492        )
493        .unwrap();
494        let expected = "spize-rotate-key:v1\nagent=spize:acme/alice:aabbcc\nold_pub=1111111111111111111111111111111111111111111111111111111111111111\nnew_pub=2222222222222222222222222222222222222222222222222222222222222222\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
495        assert_eq!(bytes, expected.as_bytes());
496    }
497
498    #[test]
499    fn rotate_key_different_new_key_yields_different_bytes() {
500        let a = rotate_key_challenge_bytes(
501            "spize:acme/alice:aabbcc",
502            "1111111111111111111111111111111111111111111111111111111111111111",
503            "2222222222222222222222222222222222222222222222222222222222222222",
504            "0123456789abcdef0123456789abcdef",
505            1_700_000_000,
506        )
507        .unwrap();
508        let b = rotate_key_challenge_bytes(
509            "spize:acme/alice:aabbcc",
510            "1111111111111111111111111111111111111111111111111111111111111111",
511            "3333333333333333333333333333333333333333333333333333333333333333",
512            "0123456789abcdef0123456789abcdef",
513            1_700_000_000,
514        )
515        .unwrap();
516        assert_ne!(a, b);
517    }
518
519    #[test]
520    fn rotate_key_rejects_same_old_and_new() {
521        let err = rotate_key_challenge_bytes(
522            "spize:acme/alice:aabbcc",
523            "1111111111111111111111111111111111111111111111111111111111111111",
524            "1111111111111111111111111111111111111111111111111111111111111111",
525            "0123456789abcdef0123456789abcdef",
526            1_700_000_000,
527        )
528        .unwrap_err();
529        assert!(matches!(err, Error::Internal(_)));
530    }
531
532    #[test]
533    fn rotate_key_rejects_newline_in_agent_id() {
534        let err = rotate_key_challenge_bytes(
535            "spize:acme/alice:\naabbcc",
536            "1111111111111111111111111111111111111111111111111111111111111111",
537            "2222222222222222222222222222222222222222222222222222222222222222",
538            "0123456789abcdef0123456789abcdef",
539            1_700_000_000,
540        )
541        .unwrap_err();
542        assert!(matches!(err, Error::Internal(_)));
543    }
544
545    #[test]
546    fn rotate_key_rejects_short_nonce() {
547        let err = rotate_key_challenge_bytes(
548            "spize:acme/alice:aabbcc",
549            "1111111111111111111111111111111111111111111111111111111111111111",
550            "2222222222222222222222222222222222222222222222222222222222222222",
551            "deadbeef",
552            1_700_000_000,
553        )
554        .unwrap_err();
555        assert!(matches!(err, Error::Internal(_)));
556    }
557
558    #[test]
559    fn data_ticket_rejects_newline_url() {
560        let err = data_ticket_bytes(
561            "tx_abc",
562            "spize:acme/bob:ddeeff",
563            "https://evil.test\nspoof",
564            1,
565            "0123456789abcdef0123456789abcdef",
566        )
567        .unwrap_err();
568        assert!(matches!(err, Error::Internal(_)));
569    }
570}