Skip to main content

aex_core/
wire_v2.rs

1//! Wire format **v2** — namespace-agnostic, AEX-branded prefix.
2//!
3//! This module is the v2 counterpart of [`crate::wire`]. The only semantic
4//! changes versus v1 are:
5//!
6//! 1. **Prefix is brand-neutral**: every canonical message starts with
7//!    `aex-<msg>:v2` instead of `spize-<msg>:v1`. The wire format no longer
8//!    embeds a vendor name in cryptographically signed bytes.
9//! 2. **Tighter clock skew window**: 60 seconds (down from 300s in v1).
10//!    Aligns with JWT/OAuth2 RFC 7519 §4.1.4 norms — see ADR-0044.
11//!    AgentId values inside the payload are expected to be W3C DID URIs
12//!    (`did:method:specific-id[#fragment]`), but legacy `spize:` strings
13//!    are still accepted at parse-time for the v1→v2 grace window.
14//!
15//! The byte-level shape (line-based, LF terminator, no trailing LF,
16//! ASCII-only fields) is **identical** to v1. Existing signers/verifiers
17//! that operate on raw bytes need only swap the bytes-producing function.
18//!
19//! See [`crate::wire`] for the v1 canonical formats kept stable for the
20//! 30-day sunset grace defined in ADR-0036.
21
22use crate::{Error, Result};
23
24/// Wire protocol version produced by this module.
25pub const PROTOCOL_VERSION_V2: &str = "v2";
26
27/// Maximum acceptable clock skew between client and server for v2 messages,
28/// in seconds. Tighter than v1's 300s; see ADR-0044.
29pub const MAX_CLOCK_SKEW_SECS_V2: i64 = 60;
30
31/// Minimum nonce length (hex chars). 32 chars = 128 bits of entropy.
32/// Unchanged from v1 — entropy budget is the same regardless of prefix.
33pub const MIN_NONCE_LEN: usize = 32;
34
35/// Maximum nonce length (hex chars). Prevents pathological inputs.
36pub const MAX_NONCE_LEN: usize = 128;
37
38/// Check if `issued_at` is within the v2 allowed skew relative to `now`.
39/// Overflow-safe under all `i64` inputs.
40pub fn is_within_clock_skew_v2(now_unix: i64, issued_at_unix: i64) -> bool {
41    let diff = (now_unix as i128).saturating_sub(issued_at_unix as i128);
42    diff.unsigned_abs() <= MAX_CLOCK_SKEW_SECS_V2 as u128
43}
44
45/// Produce the canonical bytes a client signs to register an agent (v2).
46///
47/// Format:
48/// ```text
49/// aex-register:v2
50/// pub={public_key_hex}
51/// org={org}
52/// name={name}
53/// nonce={nonce}
54/// ts={issued_at_unix}
55/// ```
56///
57/// All inputs must be ASCII. Returns an error if any field contains
58/// characters that could create canonicalization ambiguity (newlines,
59/// NULs, non-ASCII).
60pub fn registration_challenge_bytes_v2(
61    public_key_hex: &str,
62    org: &str,
63    name: &str,
64    nonce: &str,
65    issued_at_unix: i64,
66) -> Result<Vec<u8>> {
67    validate_ascii_line(public_key_hex, "public_key_hex")?;
68    validate_ascii_line(org, "org")?;
69    validate_ascii_line(name, "name")?;
70    validate_nonce(nonce)?;
71
72    let msg = format!(
73        "aex-register:{version}\npub={pub}\norg={org}\nname={name}\nnonce={nonce}\nts={ts}",
74        version = PROTOCOL_VERSION_V2,
75        pub = public_key_hex,
76        org = org,
77        name = name,
78        nonce = nonce,
79        ts = issued_at_unix,
80    );
81    Ok(msg.into_bytes())
82}
83
84/// Canonical bytes signed by the **sender** when initiating a transfer (v2).
85///
86/// Format:
87/// ```text
88/// aex-transfer-intent:v2
89/// sender={sender_agent_id}
90/// recipient={recipient}
91/// size={size_bytes}
92/// mime={declared_mime_or_empty}
93/// filename={filename_or_empty}
94/// nonce={nonce}
95/// ts={issued_at_unix}
96/// ```
97///
98/// `sender_agent_id` and `recipient` are expected to be either W3C DID
99/// URIs (`did:method:id[#fragment]`) or legacy `spize:` ids during the
100/// dual-wire grace window.
101pub fn transfer_intent_bytes_v2(
102    sender_agent_id: &str,
103    recipient: &str,
104    size_bytes: u64,
105    declared_mime: &str,
106    filename: &str,
107    nonce: &str,
108    issued_at_unix: i64,
109) -> Result<Vec<u8>> {
110    validate_ascii_line(sender_agent_id, "sender_agent_id")?;
111    validate_ascii_line(recipient, "recipient")?;
112    validate_ascii_line_opt(declared_mime, "declared_mime")?;
113    validate_ascii_line_opt(filename, "filename")?;
114    validate_nonce(nonce)?;
115
116    let msg = format!(
117        "aex-transfer-intent:{version}\nsender={sender}\nrecipient={recipient}\nsize={size}\nmime={mime}\nfilename={filename}\nnonce={nonce}\nts={ts}",
118        version = PROTOCOL_VERSION_V2,
119        sender = sender_agent_id,
120        recipient = recipient,
121        size = size_bytes,
122        mime = declared_mime,
123        filename = filename,
124        nonce = nonce,
125        ts = issued_at_unix,
126    );
127    Ok(msg.into_bytes())
128}
129
130/// Canonical bytes signed by the control plane when issuing a data-plane
131/// ticket (v2). Semantically identical to v1; only the prefix changes.
132///
133/// ```text
134/// aex-data-ticket:v2
135/// transfer={transfer_id}
136/// recipient={recipient_agent_id}
137/// data_plane={data_plane_url}
138/// expires={expires_unix}
139/// nonce={nonce}
140/// ```
141pub fn data_ticket_bytes_v2(
142    transfer_id: &str,
143    recipient_agent_id: &str,
144    data_plane_url: &str,
145    expires_unix: i64,
146    nonce: &str,
147) -> Result<Vec<u8>> {
148    validate_ascii_line(transfer_id, "transfer_id")?;
149    validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
150    validate_ascii_line(data_plane_url, "data_plane_url")?;
151    validate_nonce(nonce)?;
152
153    let msg = format!(
154        "aex-data-ticket:{version}\ntransfer={tx}\nrecipient={rec}\ndata_plane={dp}\nexpires={exp}\nnonce={nonce}",
155        version = PROTOCOL_VERSION_V2,
156        tx = transfer_id,
157        rec = recipient_agent_id,
158        dp = data_plane_url,
159        exp = expires_unix,
160        nonce = nonce,
161    );
162    Ok(msg.into_bytes())
163}
164
165/// Canonical bytes signed by an agent's current key when requesting a
166/// key rotation (v2). Mirrors the v1 protocol defined in ADR-0024.
167///
168/// ```text
169/// aex-rotate-key:v2
170/// agent={agent_id}
171/// old_pub={current_public_key_hex}
172/// new_pub={new_public_key_hex}
173/// nonce={nonce}
174/// ts={issued_at_unix}
175/// ```
176pub fn rotate_key_challenge_bytes_v2(
177    agent_id: &str,
178    old_public_key_hex: &str,
179    new_public_key_hex: &str,
180    nonce: &str,
181    issued_at_unix: i64,
182) -> Result<Vec<u8>> {
183    validate_ascii_line(agent_id, "agent_id")?;
184    validate_ascii_line(old_public_key_hex, "old_public_key_hex")?;
185    validate_ascii_line(new_public_key_hex, "new_public_key_hex")?;
186    validate_nonce(nonce)?;
187
188    if old_public_key_hex == new_public_key_hex {
189        return Err(Error::Internal(
190            "old_public_key_hex and new_public_key_hex must differ".into(),
191        ));
192    }
193
194    let msg = format!(
195        "aex-rotate-key:{version}\nagent={agent}\nold_pub={old}\nnew_pub={new}\nnonce={nonce}\nts={ts}",
196        version = PROTOCOL_VERSION_V2,
197        agent = agent_id,
198        old = old_public_key_hex,
199        new = new_public_key_hex,
200        nonce = nonce,
201        ts = issued_at_unix,
202    );
203    Ok(msg.into_bytes())
204}
205
206/// Canonical bytes signed by the **recipient** when requesting a blob or
207/// acknowledging delivery (v2).
208pub fn transfer_receipt_bytes_v2(
209    recipient_agent_id: &str,
210    transfer_id: &str,
211    action: &str,
212    nonce: &str,
213    issued_at_unix: i64,
214) -> Result<Vec<u8>> {
215    validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
216    validate_ascii_line(transfer_id, "transfer_id")?;
217    validate_ascii_line(action, "action")?;
218    validate_nonce(nonce)?;
219
220    if !matches!(action, "download" | "ack" | "inbox" | "request_ticket") {
221        return Err(Error::Internal(format!(
222            "action must be 'download', 'ack', 'inbox' or 'request_ticket', got {}",
223            action
224        )));
225    }
226
227    let msg = format!(
228        "aex-transfer-receipt:{version}\nrecipient={rec}\ntransfer={tx}\naction={act}\nnonce={nonce}\nts={ts}",
229        version = PROTOCOL_VERSION_V2,
230        rec = recipient_agent_id,
231        tx = transfer_id,
232        act = action,
233        nonce = nonce,
234        ts = issued_at_unix,
235    );
236    Ok(msg.into_bytes())
237}
238
239/// Canonical bytes for an `aex-decision-request:v2` message.
240///
241/// Signed by the **recipient** and returned to the sender when an
242/// inbound transfer cannot be answered synchronously — typically
243/// because a deferred decider (human, secondary AI, policy engine,
244/// or consensus) needs time to evaluate the request. The protocol
245/// takes no position on who the decider is.
246///
247/// Format:
248/// ```text
249/// aex-decision-request:v2
250/// recipient={recipient_agent_id}
251/// transfer={transfer_id}
252/// decision={decision_id}
253/// eta_secs={eta_seconds}
254/// nonce={nonce}
255/// ts={issued_at_unix}
256/// ```
257///
258/// `eta_seconds` is a non-negative integer hint for how long the
259/// sender should expect to wait. `0` means "as soon as practical";
260/// negative values are rejected.
261///
262/// Reference: ADR-0049.
263pub fn decision_request_bytes_v2(
264    recipient_agent_id: &str,
265    transfer_id: &str,
266    decision_id: &str,
267    eta_seconds: u64,
268    nonce: &str,
269    issued_at_unix: i64,
270) -> Result<Vec<u8>> {
271    validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
272    validate_ascii_line(transfer_id, "transfer_id")?;
273    validate_ascii_line(decision_id, "decision_id")?;
274    validate_nonce(nonce)?;
275
276    let msg = format!(
277        "aex-decision-request:{version}\nrecipient={rec}\ntransfer={tx}\ndecision={dec}\neta_secs={eta}\nnonce={nonce}\nts={ts}",
278        version = PROTOCOL_VERSION_V2,
279        rec = recipient_agent_id,
280        tx = transfer_id,
281        dec = decision_id,
282        eta = eta_seconds,
283        nonce = nonce,
284        ts = issued_at_unix,
285    );
286    Ok(msg.into_bytes())
287}
288
289/// Canonical bytes for an `aex-decision-response:v2` message.
290///
291/// Signed by the **recipient** and sent to the sender once the
292/// deferred decision has been taken. Carries the outcome and an
293/// optional human-readable reason. Once a response is delivered for
294/// a given `decision_id`, the decision is final — changing one's
295/// mind requires a new transfer.
296///
297/// Format:
298/// ```text
299/// aex-decision-response:v2
300/// recipient={recipient_agent_id}
301/// transfer={transfer_id}
302/// decision={decision_id}
303/// outcome={accepted|rejected}
304/// reason={reason_or_empty}
305/// nonce={nonce}
306/// ts={issued_at_unix}
307/// ```
308///
309/// `outcome` is exactly one of `accepted` or `rejected`. `reason`
310/// is optional (empty allowed). The verifier-side MUST persist
311/// this message in the audit chain as a
312/// [`SignedDecisionReceipt`](`crate`) event so the decision is
313/// non-repudiable.
314///
315/// Reference: ADR-0049.
316pub fn decision_response_bytes_v2(
317    recipient_agent_id: &str,
318    transfer_id: &str,
319    decision_id: &str,
320    outcome: &str,
321    reason: &str,
322    nonce: &str,
323    issued_at_unix: i64,
324) -> Result<Vec<u8>> {
325    validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
326    validate_ascii_line(transfer_id, "transfer_id")?;
327    validate_ascii_line(decision_id, "decision_id")?;
328    validate_ascii_line(outcome, "outcome")?;
329    validate_ascii_line_opt(reason, "reason")?;
330    validate_nonce(nonce)?;
331
332    if !matches!(outcome, "accepted" | "rejected") {
333        return Err(Error::Internal(format!(
334            "outcome must be 'accepted' or 'rejected', got {}",
335            outcome
336        )));
337    }
338
339    let msg = format!(
340        "aex-decision-response:{version}\nrecipient={rec}\ntransfer={tx}\ndecision={dec}\noutcome={out}\nreason={reason}\nnonce={nonce}\nts={ts}",
341        version = PROTOCOL_VERSION_V2,
342        rec = recipient_agent_id,
343        tx = transfer_id,
344        dec = decision_id,
345        out = outcome,
346        reason = reason,
347        nonce = nonce,
348        ts = issued_at_unix,
349    );
350    Ok(msg.into_bytes())
351}
352
353// ── shared validators (duplicate of wire.rs internals; intentional to
354// keep v1 untouched and v2 self-contained) ──────────────────────────
355
356fn validate_ascii_line(s: &str, field: &str) -> Result<()> {
357    if s.is_empty() {
358        return Err(Error::Internal(format!("{} is empty", field)));
359    }
360    for (i, c) in s.chars().enumerate() {
361        if !c.is_ascii() || c == '\n' || c == '\r' || c == '\0' {
362            return Err(Error::Internal(format!(
363                "{} has invalid char at {}: {:?}",
364                field, i, c
365            )));
366        }
367    }
368    Ok(())
369}
370
371fn validate_ascii_line_opt(s: &str, field: &str) -> Result<()> {
372    if s.is_empty() {
373        return Ok(());
374    }
375    validate_ascii_line(s, field)
376}
377
378fn validate_nonce(nonce: &str) -> Result<()> {
379    if nonce.len() < MIN_NONCE_LEN || nonce.len() > MAX_NONCE_LEN {
380        return Err(Error::Internal(format!(
381            "nonce length {} outside [{}, {}]",
382            nonce.len(),
383            MIN_NONCE_LEN,
384            MAX_NONCE_LEN
385        )));
386    }
387    if !nonce.chars().all(|c| c.is_ascii_hexdigit()) {
388        return Err(Error::Internal("nonce must be hex".into()));
389    }
390    Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    const NONCE: &str = "0123456789abcdef0123456789abcdef";
398
399    #[test]
400    fn v2_register_canonical_bytes_stable() {
401        let bytes =
402            registration_challenge_bytes_v2("aabbcc", "acme", "alice", NONCE, 1_700_000_000)
403                .unwrap();
404        let expected = "aex-register:v2\npub=aabbcc\norg=acme\nname=alice\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
405        assert_eq!(bytes, expected.as_bytes());
406    }
407
408    #[test]
409    fn v2_transfer_intent_uses_did_uri() {
410        let bytes = transfer_intent_bytes_v2(
411            "did:web:acme.com#agent-vendite",
412            "did:web:beta-corp.com#acquisti",
413            12345,
414            "application/pdf",
415            "invoice.pdf",
416            NONCE,
417            1_700_000_000,
418        )
419        .unwrap();
420        let expected = "aex-transfer-intent:v2\nsender=did:web:acme.com#agent-vendite\nrecipient=did:web:beta-corp.com#acquisti\nsize=12345\nmime=application/pdf\nfilename=invoice.pdf\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
421        assert_eq!(bytes, expected.as_bytes());
422    }
423
424    #[test]
425    fn v2_transfer_intent_with_legacy_spize_id() {
426        // During grace window, v2 wire still accepts legacy `spize:` ids.
427        let bytes = transfer_intent_bytes_v2(
428            "spize:acme/alice:aabbcc",
429            "did:ethr:0x14a34:0xabc",
430            100,
431            "",
432            "",
433            NONCE,
434            1_700_000_000,
435        )
436        .unwrap();
437        let s = std::str::from_utf8(&bytes).unwrap();
438        assert!(s.starts_with("aex-transfer-intent:v2\n"));
439        assert!(s.contains("sender=spize:acme/alice:aabbcc\n"));
440        assert!(s.contains("recipient=did:ethr:0x14a34:0xabc\n"));
441    }
442
443    #[test]
444    fn v2_data_ticket_stable() {
445        let bytes = data_ticket_bytes_v2(
446            "tx_abc123",
447            "did:web:acme.com#bob",
448            "https://data.acme.com",
449            1_700_000_100,
450            NONCE,
451        )
452        .unwrap();
453        let expected = "aex-data-ticket:v2\ntransfer=tx_abc123\nrecipient=did:web:acme.com#bob\ndata_plane=https://data.acme.com\nexpires=1700000100\nnonce=0123456789abcdef0123456789abcdef";
454        assert_eq!(bytes, expected.as_bytes());
455    }
456
457    #[test]
458    fn v2_rotate_key_stable() {
459        let old = "1".repeat(64);
460        let new = "2".repeat(64);
461        let bytes = rotate_key_challenge_bytes_v2(
462            "did:spize:acme/alice#aabbcc",
463            &old,
464            &new,
465            NONCE,
466            1_700_000_000,
467        )
468        .unwrap();
469        let s = std::str::from_utf8(&bytes).unwrap();
470        assert!(s.starts_with("aex-rotate-key:v2\n"));
471        assert!(s.contains("agent=did:spize:acme/alice#aabbcc\n"));
472    }
473
474    #[test]
475    fn v2_receipt_stable() {
476        let bytes = transfer_receipt_bytes_v2(
477            "did:web:beta-corp.com#acquisti",
478            "tx_abc123",
479            "ack",
480            NONCE,
481            1_700_000_000,
482        )
483        .unwrap();
484        let expected = "aex-transfer-receipt:v2\nrecipient=did:web:beta-corp.com#acquisti\ntransfer=tx_abc123\naction=ack\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
485        assert_eq!(bytes, expected.as_bytes());
486    }
487
488    #[test]
489    fn v2_clock_skew_60s_window() {
490        let now = 1_700_000_000_i64;
491        assert!(is_within_clock_skew_v2(now, now));
492        assert!(is_within_clock_skew_v2(now, now - 60));
493        assert!(is_within_clock_skew_v2(now, now + 60));
494        assert!(!is_within_clock_skew_v2(now, now - 61));
495        assert!(!is_within_clock_skew_v2(now, now + 61));
496    }
497
498    #[test]
499    fn v2_clock_skew_extreme_inputs_do_not_panic() {
500        let now = 1_700_000_000_i64;
501        assert!(!is_within_clock_skew_v2(now, i64::MIN));
502        assert!(!is_within_clock_skew_v2(now, i64::MAX));
503        assert!(!is_within_clock_skew_v2(i64::MAX, i64::MIN));
504    }
505
506    #[test]
507    fn v2_newline_in_field_rejected() {
508        let err = registration_challenge_bytes_v2("aa", "ac\nme", "alice", NONCE, 100).unwrap_err();
509        assert!(matches!(err, Error::Internal(_)));
510    }
511
512    #[test]
513    fn v2_non_ascii_field_rejected() {
514        let err = registration_challenge_bytes_v2("aa", "acmè", "alice", NONCE, 100).unwrap_err();
515        assert!(matches!(err, Error::Internal(_)));
516    }
517
518    #[test]
519    fn v2_short_nonce_rejected() {
520        let err =
521            registration_challenge_bytes_v2("aa", "acme", "alice", "deadbeef", 100).unwrap_err();
522        assert!(matches!(err, Error::Internal(_)));
523    }
524
525    #[test]
526    fn v2_non_hex_nonce_rejected() {
527        let err = registration_challenge_bytes_v2("aa", "acme", "alice", &"z".repeat(32), 100)
528            .unwrap_err();
529        assert!(matches!(err, Error::Internal(_)));
530    }
531
532    #[test]
533    fn v2_rotate_key_rejects_same_old_and_new() {
534        let same = "a".repeat(64);
535        let err = rotate_key_challenge_bytes_v2(
536            "did:spize:acme/alice#aabbcc",
537            &same,
538            &same,
539            NONCE,
540            1_700_000_000,
541        )
542        .unwrap_err();
543        assert!(matches!(err, Error::Internal(_)));
544    }
545
546    #[test]
547    fn v2_receipt_rejects_bad_action() {
548        let err =
549            transfer_receipt_bytes_v2("did:web:beta-corp.com#bob", "tx_abc", "overwrite", NONCE, 1)
550                .unwrap_err();
551        assert!(matches!(err, Error::Internal(_)));
552    }
553
554    #[test]
555    fn v2_data_ticket_rejects_newline_url() {
556        let err = data_ticket_bytes_v2(
557            "tx_abc",
558            "did:web:acme.com#bob",
559            "https://evil.test\nspoof",
560            1,
561            NONCE,
562        )
563        .unwrap_err();
564        assert!(matches!(err, Error::Internal(_)));
565    }
566
567    #[test]
568    fn v2_decision_request_stable() {
569        let bytes = decision_request_bytes_v2(
570            "did:web:acme.com#agent-vendite",
571            "tx_abc123",
572            "dec_0001",
573            86_400,
574            NONCE,
575            1_700_000_000,
576        )
577        .unwrap();
578        let expected = "aex-decision-request:v2\nrecipient=did:web:acme.com#agent-vendite\ntransfer=tx_abc123\ndecision=dec_0001\neta_secs=86400\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
579        assert_eq!(bytes, expected.as_bytes());
580    }
581
582    #[test]
583    fn v2_decision_response_accepted_stable() {
584        let bytes = decision_response_bytes_v2(
585            "did:web:acme.com#agent-vendite",
586            "tx_abc123",
587            "dec_0001",
588            "accepted",
589            "",
590            NONCE,
591            1_700_000_000,
592        )
593        .unwrap();
594        let expected = "aex-decision-response:v2\nrecipient=did:web:acme.com#agent-vendite\ntransfer=tx_abc123\ndecision=dec_0001\noutcome=accepted\nreason=\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
595        assert_eq!(bytes, expected.as_bytes());
596    }
597
598    #[test]
599    fn v2_decision_response_rejected_with_reason_stable() {
600        let bytes = decision_response_bytes_v2(
601            "did:web:acme.com#agent-vendite",
602            "tx_abc123",
603            "dec_0001",
604            "rejected",
605            "operator declined: budget exceeded",
606            NONCE,
607            1_700_000_000,
608        )
609        .unwrap();
610        let s = std::str::from_utf8(&bytes).unwrap();
611        assert!(s.starts_with("aex-decision-response:v2\n"));
612        assert!(s.contains("outcome=rejected\n"));
613        assert!(s.contains("reason=operator declined: budget exceeded\n"));
614    }
615
616    #[test]
617    fn v2_decision_response_rejects_bad_outcome() {
618        let err = decision_response_bytes_v2(
619            "did:web:acme.com#agent-vendite",
620            "tx_abc123",
621            "dec_0001",
622            "maybe",
623            "",
624            NONCE,
625            1_700_000_000,
626        )
627        .unwrap_err();
628        assert!(matches!(err, Error::Internal(_)));
629    }
630
631    #[test]
632    fn v2_decision_request_rejects_newline_in_fields() {
633        let err = decision_request_bytes_v2(
634            "did:web:acme.com#agent-vendite",
635            "tx_abc\n123",
636            "dec_0001",
637            60,
638            NONCE,
639            1_700_000_000,
640        )
641        .unwrap_err();
642        assert!(matches!(err, Error::Internal(_)));
643    }
644
645    #[test]
646    fn v2_decision_messages_distinguish_request_from_response() {
647        // Two messages with the same business fields must produce
648        // distinct bytes — the prefix differs (request vs response),
649        // so signatures bound to one cannot replay as the other.
650        let req = decision_request_bytes_v2(
651            "did:web:acme.com#x",
652            "tx_1",
653            "dec_1",
654            60,
655            NONCE,
656            1_700_000_000,
657        )
658        .unwrap();
659        let resp = decision_response_bytes_v2(
660            "did:web:acme.com#x",
661            "tx_1",
662            "dec_1",
663            "accepted",
664            "",
665            NONCE,
666            1_700_000_000,
667        )
668        .unwrap();
669        assert_ne!(req, resp);
670        assert!(std::str::from_utf8(&req)
671            .unwrap()
672            .starts_with("aex-decision-request:v2\n"));
673        assert!(std::str::from_utf8(&resp)
674            .unwrap()
675            .starts_with("aex-decision-response:v2\n"));
676    }
677
678    #[test]
679    fn v2_prefix_differs_from_v1_for_identical_inputs() {
680        // Critical invariant: v1 and v2 bytes for the same logical message
681        // are NEVER equal — they encode different wire versions and any
682        // signature verifier must distinguish them.
683        let v1 = crate::wire::registration_challenge_bytes(
684            "aabbcc",
685            "acme",
686            "alice",
687            NONCE,
688            1_700_000_000,
689        )
690        .unwrap();
691        let v2 = registration_challenge_bytes_v2("aabbcc", "acme", "alice", NONCE, 1_700_000_000)
692            .unwrap();
693        assert_ne!(v1, v2);
694        // Specifically: v1 starts with "spize-", v2 with "aex-".
695        assert!(std::str::from_utf8(&v1).unwrap().starts_with("spize-"));
696        assert!(std::str::from_utf8(&v2).unwrap().starts_with("aex-"));
697    }
698}