1use std::fmt;
19use std::future::Future;
20use std::pin::Pin;
21use std::time::Duration;
22
23use aex_core::{
24 capability::Capability, wire, wire_v2, AgentId, CapabilitySet, IdScheme, IdentityProvider,
25};
26use aex_identity::DidKeyProvider;
27use aex_jws::{sign_ed25519, verify, JwsError, VerifierKey};
28use aex_net::is_forbidden_ip;
29use ed25519_dalek::SigningKey;
30
31#[derive(Debug, Clone)]
33pub struct ConformanceResult {
34 pub id: &'static str,
37 pub category: &'static str,
40 pub outcome: Outcome,
42}
43
44#[derive(Debug, Clone)]
46pub enum Outcome {
47 Pass,
49 Fail(String),
51}
52
53impl Outcome {
54 pub fn is_pass(&self) -> bool {
56 matches!(self, Outcome::Pass)
57 }
58}
59
60impl fmt::Display for Outcome {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Outcome::Pass => write!(f, "PASS"),
64 Outcome::Fail(msg) => write!(f, "FAIL: {}", msg),
65 }
66 }
67}
68
69pub type TestFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
71
72pub type TestRunner = fn() -> TestFuture;
74
75pub struct ConformanceTest {
77 pub id: &'static str,
79 pub category: &'static str,
81 pub run: TestRunner,
83}
84
85pub fn all_tests() -> Vec<ConformanceTest> {
91 vec![
92 ConformanceTest {
93 id: "wire-v2-roundtrip",
94 category: "wire",
95 run: || Box::pin(test_wire_v2_roundtrip()),
96 },
97 ConformanceTest {
98 id: "wire-v1-still-functional",
99 category: "wire",
100 run: || Box::pin(test_wire_v1_still_functional()),
101 },
102 ConformanceTest {
103 id: "cross-version-isolation",
104 category: "wire",
105 run: || Box::pin(test_cross_version_isolation()),
106 },
107 ConformanceTest {
108 id: "jws-algorithm-whitelist",
109 category: "jws",
110 run: || Box::pin(test_jws_algorithm_whitelist()),
111 },
112 ConformanceTest {
113 id: "jws-alg-none-rejected",
114 category: "jws",
115 run: || Box::pin(test_jws_alg_none_rejected()),
116 },
117 ConformanceTest {
118 id: "jws-alg-hs256-rejected",
119 category: "jws",
120 run: || Box::pin(test_jws_alg_hs256_rejected()),
121 },
122 ConformanceTest {
123 id: "jws-tampered-payload-rejected",
124 category: "jws",
125 run: || Box::pin(test_jws_tampered_payload_rejected()),
126 },
127 ConformanceTest {
128 id: "ssrf-rejects-loopback",
129 category: "ssrf",
130 run: || Box::pin(test_ssrf_rejects_loopback()),
131 },
132 ConformanceTest {
133 id: "ssrf-rejects-rfc1918",
134 category: "ssrf",
135 run: || Box::pin(test_ssrf_rejects_rfc1918()),
136 },
137 ConformanceTest {
138 id: "ssrf-rejects-link-local",
139 category: "ssrf",
140 run: || Box::pin(test_ssrf_rejects_link_local()),
141 },
142 ConformanceTest {
143 id: "ssrf-accepts-public-ips",
144 category: "ssrf",
145 run: || Box::pin(test_ssrf_accepts_public_ips()),
146 },
147 ConformanceTest {
148 id: "clock-skew-60s-window",
149 category: "time",
150 run: || Box::pin(test_clock_skew_60s_window()),
151 },
152 ConformanceTest {
153 id: "clock-skew-rejects-outside-window",
154 category: "time",
155 run: || Box::pin(test_clock_skew_rejects_outside_window()),
156 },
157 ConformanceTest {
158 id: "did-uri-parser-strict",
159 category: "identity",
160 run: || Box::pin(test_did_uri_parser_strict()),
161 },
162 ConformanceTest {
163 id: "did-key-roundtrip",
164 category: "identity",
165 run: || Box::pin(test_did_key_roundtrip()),
166 },
167 ConformanceTest {
168 id: "did-key-rejects-malformed",
169 category: "identity",
170 run: || Box::pin(test_did_key_rejects_malformed()),
171 },
172 ConformanceTest {
173 id: "capability-bits-stable",
174 category: "capability",
175 run: || Box::pin(test_capability_bits_stable()),
176 },
177 ConformanceTest {
178 id: "capability-forward-compat",
179 category: "capability",
180 run: || Box::pin(test_capability_forward_compat()),
181 },
182 ConformanceTest {
183 id: "wire-v2-rejects-nonce-too-short",
184 category: "wire",
185 run: || Box::pin(test_wire_v2_rejects_nonce_too_short()),
186 },
187 ConformanceTest {
188 id: "wire-v2-rejects-newline-in-fields",
189 category: "wire",
190 run: || Box::pin(test_wire_v2_rejects_newline_in_fields()),
191 },
192 ConformanceTest {
193 id: "wire-v2-rotate-key-same-keys-rejected",
194 category: "wire",
195 run: || Box::pin(test_wire_v2_rotate_key_same_keys_rejected()),
196 },
197 ConformanceTest {
198 id: "wire-v2-receipt-action-whitelist",
199 category: "wire",
200 run: || Box::pin(test_wire_v2_receipt_action_whitelist()),
201 },
202 ConformanceTest {
203 id: "decision-request-bytes-stable",
204 category: "deferred-decision",
205 run: || Box::pin(test_decision_request_bytes_stable()),
206 },
207 ConformanceTest {
208 id: "decision-response-bytes-stable",
209 category: "deferred-decision",
210 run: || Box::pin(test_decision_response_bytes_stable()),
211 },
212 ConformanceTest {
213 id: "deferred-decision-capability-bit-stable",
214 category: "deferred-decision",
215 run: || Box::pin(test_deferred_decision_capability_bit_stable()),
216 },
217 ]
218}
219
220pub async fn run_all() -> Vec<ConformanceResult> {
222 let mut results = Vec::new();
223 for t in all_tests() {
224 let outcome = match (t.run)().await {
225 Ok(()) => Outcome::Pass,
226 Err(msg) => Outcome::Fail(msg),
227 };
228 results.push(ConformanceResult {
229 id: t.id,
230 category: t.category,
231 outcome,
232 });
233 }
234 results
235}
236
237const NONCE: &str = "0123456789abcdef0123456789abcdef";
240
241async fn test_wire_v2_roundtrip() -> Result<(), String> {
242 let bytes = wire_v2::transfer_intent_bytes_v2(
243 "did:web:acme.com#agent",
244 "did:web:beta.com#bob",
245 12345,
246 "application/pdf",
247 "x.pdf",
248 NONCE,
249 1_700_000_000,
250 )
251 .map_err(|e| format!("encode failed: {}", e))?;
252 let s = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
253 if !s.starts_with("aex-transfer-intent:v2\n") {
254 return Err(format!("unexpected prefix: {:?}", &s[..30]));
255 }
256 Ok(())
257}
258
259async fn test_wire_v1_still_functional() -> Result<(), String> {
260 let bytes = wire::transfer_intent_bytes(
261 "spize:acme/alice:aabbcc",
262 "spize:acme/bob:ddeeff",
263 100,
264 "",
265 "",
266 NONCE,
267 1_700_000_000,
268 )
269 .map_err(|e| e.to_string())?;
270 let s = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
271 if !s.starts_with("spize-transfer-intent:v1\n") {
272 return Err(format!("v1 prefix missing: {:?}", &s[..30]));
273 }
274 Ok(())
275}
276
277async fn test_cross_version_isolation() -> Result<(), String> {
278 let v1 = wire::registration_challenge_bytes("aa", "acme", "alice", NONCE, 1_700_000_000)
279 .map_err(|e| e.to_string())?;
280 let v2 = wire_v2::registration_challenge_bytes_v2("aa", "acme", "alice", NONCE, 1_700_000_000)
281 .map_err(|e| e.to_string())?;
282 if v1 == v2 {
283 return Err("v1 and v2 bytes collide for identical inputs".into());
284 }
285 if !std::str::from_utf8(&v1).unwrap().starts_with("spize-") {
286 return Err("v1 missing spize- prefix".into());
287 }
288 if !std::str::from_utf8(&v2).unwrap().starts_with("aex-") {
289 return Err("v2 missing aex- prefix".into());
290 }
291 Ok(())
292}
293
294async fn test_jws_algorithm_whitelist() -> Result<(), String> {
295 let sk = SigningKey::from_bytes(&[1u8; 32]);
296 let jws = sign_ed25519(b"payload", &sk, "did:key:test").map_err(|e| e.to_string())?;
297 let _ = verify(&jws, |_| Ok(Some(VerifierKey::Ed25519(sk.verifying_key()))))
298 .map_err(|e| e.to_string())?;
299 Ok(())
300}
301
302async fn test_jws_alg_none_rejected() -> Result<(), String> {
303 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
304 use base64::Engine;
305 let header = serde_json::json!({"alg": "none", "kid": "did:web:attacker"});
306 let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
307 let p = URL_SAFE_NO_PAD.encode(b"forged");
308 let jws = format!("{}.{}.", h, p);
309 match verify(&jws, |_| Ok(None)) {
310 Err(JwsError::AlgorithmNotPermitted(s)) if s == "none" => Ok(()),
311 other => Err(format!("expected AlgorithmNotPermitted, got {:?}", other)),
312 }
313}
314
315async fn test_jws_alg_hs256_rejected() -> Result<(), String> {
316 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
317 use base64::Engine;
318 let header = serde_json::json!({"alg": "HS256", "kid": "did:web:attacker"});
319 let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
320 let p = URL_SAFE_NO_PAD.encode(b"forged");
321 let s = URL_SAFE_NO_PAD.encode([0u8; 32]);
322 let jws = format!("{}.{}.{}", h, p, s);
323 match verify(&jws, |_| Ok(None)) {
324 Err(JwsError::AlgorithmNotPermitted(s)) if s == "HS256" => Ok(()),
325 other => Err(format!("expected AlgorithmNotPermitted, got {:?}", other)),
326 }
327}
328
329async fn test_jws_tampered_payload_rejected() -> Result<(), String> {
330 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
331 use base64::Engine;
332 let sk = SigningKey::from_bytes(&[2u8; 32]);
333 let vk = sk.verifying_key();
334 let jws = sign_ed25519(b"original", &sk, "did:web:bob").unwrap();
335 let parts: Vec<&str> = jws.split('.').collect();
336 let tampered_p = URL_SAFE_NO_PAD.encode(b"forged");
337 let bad = format!("{}.{}.{}", parts[0], tampered_p, parts[2]);
338 match verify(&bad, |_| Ok(Some(VerifierKey::Ed25519(vk)))) {
339 Err(JwsError::BadSignature(_)) => Ok(()),
340 other => Err(format!("expected BadSignature, got {:?}", other)),
341 }
342}
343
344async fn test_ssrf_rejects_loopback() -> Result<(), String> {
345 if !is_forbidden_ip("127.0.0.1".parse().unwrap()) {
346 return Err("127.0.0.1 not flagged forbidden".into());
347 }
348 if !is_forbidden_ip("::1".parse().unwrap()) {
349 return Err("::1 not flagged forbidden".into());
350 }
351 Ok(())
352}
353
354async fn test_ssrf_rejects_rfc1918() -> Result<(), String> {
355 for ip in ["10.0.0.1", "172.16.0.1", "192.168.0.1"] {
356 if !is_forbidden_ip(ip.parse().unwrap()) {
357 return Err(format!("{} not flagged forbidden", ip));
358 }
359 }
360 Ok(())
361}
362
363async fn test_ssrf_rejects_link_local() -> Result<(), String> {
364 if !is_forbidden_ip("169.254.169.254".parse().unwrap()) {
365 return Err("EC2 metadata IP 169.254.169.254 not flagged".into());
366 }
367 if !is_forbidden_ip("fe80::1".parse().unwrap()) {
368 return Err("fe80::1 not flagged".into());
369 }
370 Ok(())
371}
372
373async fn test_ssrf_accepts_public_ips() -> Result<(), String> {
374 for ip in ["1.1.1.1", "8.8.8.8"] {
375 if is_forbidden_ip(ip.parse().unwrap()) {
376 return Err(format!("public IP {} unexpectedly flagged", ip));
377 }
378 }
379 Ok(())
380}
381
382async fn test_clock_skew_60s_window() -> Result<(), String> {
383 let now = 1_700_000_000;
384 if !wire_v2::is_within_clock_skew_v2(now, now) {
385 return Err("zero skew rejected".into());
386 }
387 if !wire_v2::is_within_clock_skew_v2(now, now - 60) {
388 return Err("-60s skew rejected".into());
389 }
390 if !wire_v2::is_within_clock_skew_v2(now, now + 60) {
391 return Err("+60s skew rejected".into());
392 }
393 Ok(())
394}
395
396async fn test_clock_skew_rejects_outside_window() -> Result<(), String> {
397 let now = 1_700_000_000;
398 if wire_v2::is_within_clock_skew_v2(now, now - 61) {
399 return Err("-61s skew accepted".into());
400 }
401 if wire_v2::is_within_clock_skew_v2(now, now + 61) {
402 return Err("+61s skew accepted".into());
403 }
404 Ok(())
405}
406
407async fn test_did_uri_parser_strict() -> Result<(), String> {
408 let id = AgentId::new("did:web:acme.com#").map_err(|e| e.to_string())?;
410 if id.as_did_uri().is_some() {
411 return Err("empty fragment accepted by parser".into());
412 }
413 let id = AgentId::new("did:WEB:acme.com").map_err(|e| e.to_string())?;
415 if id.as_did_uri().is_some() {
416 return Err("uppercase method accepted".into());
417 }
418 let id = AgentId::new("did:web:acme.com#x").map_err(|e| e.to_string())?;
420 let u = id
421 .as_did_uri()
422 .ok_or_else(|| "well-formed URI failed to parse".to_string())?;
423 if u.method != "web" || u.method_specific_id != "acme.com" {
424 return Err("parse extracted wrong components".into());
425 }
426 Ok(())
427}
428
429async fn test_did_key_roundtrip() -> Result<(), String> {
430 let p = DidKeyProvider::generate().map_err(|e| e.to_string())?;
431 if p.agent_id().scheme() != IdScheme::DidKey {
432 return Err("generated did:key has wrong scheme".into());
433 }
434 let _ = DidKeyProvider::decode_pubkey(p.agent_id()).map_err(|e| e.to_string())?;
435 Ok(())
436}
437
438async fn test_did_key_rejects_malformed() -> Result<(), String> {
439 let id = AgentId::new("did:key:fabc").map_err(|e| e.to_string())?;
440 if DidKeyProvider::decode_pubkey(&id).is_ok() {
441 return Err("malformed did:key accepted".into());
442 }
443 Ok(())
444}
445
446async fn test_capability_bits_stable() -> Result<(), String> {
447 let expected: &[(Capability, u8, &str)] = &[
448 (Capability::WireV2, 0, "wire-v2"),
449 (Capability::JwsAgentCard, 1, "jws-agent-card"),
450 (Capability::CardEtag, 2, "card-etag"),
451 (Capability::A2ABridge, 3, "a2a-bridge"),
452 (Capability::EtereCitizenTrust, 4, "etere-citizen-trust"),
453 (Capability::SafeHttp, 5, "safe-http"),
454 (Capability::ClockSkew60s, 6, "clock-skew-60s"),
455 (Capability::StreamingTransfer, 7, "streaming-transfer"),
456 ];
457 for (cap, bit, name) in expected {
458 if cap.as_bit() != *bit {
459 return Err(format!(
460 "capability {:?} bit changed: {} != {}",
461 cap,
462 cap.as_bit(),
463 bit
464 ));
465 }
466 if cap.as_str() != *name {
467 return Err(format!(
468 "capability {:?} name changed: {:?} != {:?}",
469 cap,
470 cap.as_str(),
471 name
472 ));
473 }
474 }
475 Ok(())
476}
477
478async fn test_capability_forward_compat() -> Result<(), String> {
479 let set = CapabilitySet::from_string_array([
481 "wire-v2",
482 "future-capability-the-future-invented",
483 "jws-agent-card",
484 ]);
485 if !set.has(Capability::WireV2) || !set.has(Capability::JwsAgentCard) {
486 return Err("known caps lost during forward-compat parse".into());
487 }
488 if set.to_string_array().len() != 2 {
489 return Err(format!(
490 "expected 2 known caps after dropping unknown, got {}",
491 set.to_string_array().len()
492 ));
493 }
494 Ok(())
495}
496
497async fn test_wire_v2_rejects_nonce_too_short() -> Result<(), String> {
498 let r =
499 wire_v2::registration_challenge_bytes_v2("aa", "acme", "alice", "deadbeef", 1_700_000_000);
500 if r.is_ok() {
501 return Err("short nonce accepted".into());
502 }
503 Ok(())
504}
505
506async fn test_wire_v2_rejects_newline_in_fields() -> Result<(), String> {
507 let r = wire_v2::registration_challenge_bytes_v2("aa", "ac\nme", "alice", NONCE, 1_700_000_000);
508 if r.is_ok() {
509 return Err("newline in org field accepted".into());
510 }
511 Ok(())
512}
513
514async fn test_wire_v2_rotate_key_same_keys_rejected() -> Result<(), String> {
515 let same = "a".repeat(64);
516 let r = wire_v2::rotate_key_challenge_bytes_v2(
517 "did:spize:acme/alice#aabbcc",
518 &same,
519 &same,
520 NONCE,
521 1_700_000_000,
522 );
523 if r.is_ok() {
524 return Err("identical old/new keys accepted in rotate".into());
525 }
526 Ok(())
527}
528
529async fn test_wire_v2_receipt_action_whitelist() -> Result<(), String> {
530 for action in ["download", "ack", "inbox", "request_ticket"] {
532 wire_v2::transfer_receipt_bytes_v2(
533 "did:web:bob.com#x",
534 "tx_abc",
535 action,
536 NONCE,
537 1_700_000_000,
538 )
539 .map_err(|e| format!("action {} rejected: {}", action, e))?;
540 }
541 let r = wire_v2::transfer_receipt_bytes_v2(
543 "did:web:bob.com#x",
544 "tx_abc",
545 "overwrite",
546 NONCE,
547 1_700_000_000,
548 );
549 if r.is_ok() {
550 return Err("non-whitelisted action accepted".into());
551 }
552 Ok(())
553}
554
555async fn test_decision_request_bytes_stable() -> Result<(), String> {
556 let bytes = wire_v2::decision_request_bytes_v2(
557 "did:web:acme.com#agent-vendite",
558 "tx_abc123",
559 "dec_0001",
560 86_400,
561 NONCE,
562 1_700_000_000,
563 )
564 .map_err(|e| e.to_string())?;
565 let s = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
566 if !s.starts_with("aex-decision-request:v2\n") {
567 return Err(format!("unexpected prefix: {:?}", &s[..40]));
568 }
569 if !s.contains("decision=dec_0001\n") {
570 return Err("decision id field missing".into());
571 }
572 if !s.contains("eta_secs=86400\n") {
573 return Err("eta_secs field missing or malformed".into());
574 }
575 Ok(())
576}
577
578async fn test_decision_response_bytes_stable() -> Result<(), String> {
579 let accepted = wire_v2::decision_response_bytes_v2(
580 "did:web:acme.com#agent-vendite",
581 "tx_abc123",
582 "dec_0001",
583 "accepted",
584 "",
585 NONCE,
586 1_700_000_000,
587 )
588 .map_err(|e| e.to_string())?;
589 if !std::str::from_utf8(&accepted)
590 .unwrap()
591 .starts_with("aex-decision-response:v2\n")
592 {
593 return Err("accepted prefix wrong".into());
594 }
595 let bad =
597 wire_v2::decision_response_bytes_v2("x", "tx", "dec", "maybe", "", NONCE, 1_700_000_000);
598 if bad.is_ok() {
599 return Err("non-whitelisted outcome accepted".into());
600 }
601 Ok(())
602}
603
604async fn test_deferred_decision_capability_bit_stable() -> Result<(), String> {
605 if Capability::DeferredDecision.as_bit() != 8 {
606 return Err(format!(
607 "DeferredDecision bit changed: {}",
608 Capability::DeferredDecision.as_bit()
609 ));
610 }
611 if Capability::DeferredDecision.as_str() != "deferred-decision" {
612 return Err(format!(
613 "DeferredDecision name changed: {:?}",
614 Capability::DeferredDecision.as_str()
615 ));
616 }
617 let set = CapabilitySet::empty().with(Capability::DeferredDecision);
620 if !set.has(Capability::DeferredDecision) {
621 return Err("set membership broken".into());
622 }
623 let names = set.to_string_array();
624 if !names.contains(&"deferred-decision") {
625 return Err("string array missing deferred-decision".into());
626 }
627 Ok(())
628}
629
630#[doc(hidden)]
633pub const _DURATION_HINT: Duration = Duration::from_secs(0);
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[tokio::test]
640 async fn full_suite_passes_against_local_stack() {
641 let results = run_all().await;
642 let failed: Vec<_> = results.iter().filter(|r| !r.outcome.is_pass()).collect();
643 assert!(
644 failed.is_empty(),
645 "conformance failures: {:#?}",
646 failed
647 .iter()
648 .map(|r| format!("{}: {}", r.id, r.outcome))
649 .collect::<Vec<_>>()
650 );
651 assert_eq!(results.len(), 25);
653 }
654
655 #[tokio::test]
656 async fn every_test_has_unique_id() {
657 let tests = all_tests();
658 let mut seen = std::collections::HashSet::new();
659 for t in &tests {
660 assert!(seen.insert(t.id), "duplicate test id: {}", t.id);
661 }
662 }
663}