1use crate::{Error, Result};
9
10pub const PROTOCOL_VERSION: &str = "v1";
14
15pub const MAX_CLOCK_SKEW_SECS: i64 = 300;
18
19pub 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
30pub const MIN_NONCE_LEN: usize = 32;
32
33pub const MAX_NONCE_LEN: usize = 128;
35
36pub 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
78fn 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
94fn 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
102pub 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
144pub 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
185pub 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 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}