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 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
240pub 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 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}