aph_core/discovery/mod.rs
1//! Notary key discovery — the parsing half.
2//!
3//! Spec §8.4 defines three ways a verifier recovers a notary's public key
4//! with no prior trust relationship: `did:key` (offline), a DNS TXT record,
5//! and a `did:web` DID Document over HTTPS.
6//!
7//! **Only parsing lives here.** Fetching — DNS queries and HTTPS requests —
8//! is deliberately excluded so this crate stays offline, dependency-light,
9//! and fully testable: every rule below is exercised against fixed strings
10//! rather than a live network. A companion adapter crate performs the I/O
11//! and feeds the results to these functions.
12//!
13//! `did:key` needs no fetching at all, so it is complete in
14//! [`crate::crypto::did_key`] rather than here.
15//!
16//! The modules divide as follows. [`dns_txt`] and [`did_document`] parse the
17//! two fetched wire forms; [`publish`] renders those same two forms, so the
18//! pair is round-trip testable without a network; [`ports`] declares the
19//! narrow one-method ports an adapter implements to do the fetching; and
20//! [`composer`] dispatches across mechanisms in the §8.4.6 preference order
21//! with no silent downgrade from a stronger mechanism to a weaker one.
22//! [`DiscoveryOutcome`] is the one word all of them use for "the mechanism
23//! answered, and published nothing" — the distinction §8.4.6 is built on.
24//!
25//! Two members here serve a surface that is NOT key discovery: revocation
26//! status (§6.3.3) is anchored in the same `did:web` origin, so
27//! [`DidUrl::web_status_url`] derives its endpoint through the very same
28//! percent-decode-then-split rule as the DID Document URL, and
29//! [`same_origin`] is the one origin comparison this crate makes. They live
30//! beside `DidUrl` rather than in [`crate::credential_status`] because a
31//! second implementation of either rule is how two surfaces anchored in one
32//! domain quietly stop agreeing about what that domain is.
33
34pub mod composer;
35pub mod did_document;
36pub mod dns_txt;
37pub mod ports;
38pub mod publish;
39
40/// What a discovery step found, when the step itself completed.
41///
42/// Spec §8.4.6 turns on a distinction an empty collection can carry but
43/// cannot NAME — and while it was carried by prose alone, implementations of
44/// this protocol repeatedly got it backwards, in both directions.
45/// **Absence advances the mechanism sequence; failure stops it.** A
46/// mechanism that published nothing offered nothing to break, so advancing
47/// past it concedes nothing. A mechanism that was offered and then broke must
48/// reject, or whoever can break — or merely block — the strongest anchor gets
49/// to choose that the verifier trusts a weaker one instead, and choosing the
50/// anchor is an identity decision.
51///
52/// **Failure is deliberately NOT a variant here.** It stays in the `Err` arm
53/// of the enclosing `Result`, for two reasons. It carries a §11 code the
54/// caller acts on — `APH_E008` unreachable, `APH_E003` outside the validity
55/// window, `APH_E010` unsupported, `APH_E014` terminal absence — and those
56/// demand different remedies. And `?` then propagates a failure past every
57/// intermediate hop with nobody writing the propagation: the no-downgrade rule
58/// becomes something the compiler enforces rather than a discipline each call
59/// site has to remember, which is exactly where it was lost before.
60///
61/// This type appears only where the protocol defines a served-absence form.
62/// The two body-fetch ports have none — see [`ports`] for why inventing one
63/// there would both contradict §6.3.3.4 and make a response status readable
64/// from outside a verifier.
65#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq)]
66pub enum DiscoveryOutcome<T> {
67 /// The mechanism answered, and it published this.
68 Found(T),
69 /// The mechanism answered, and the answer is that nothing is published
70 /// here. An ordered chain ADVANCES to the next mechanism; a chain of one
71 /// — a pinned or terminal mechanism — has nowhere to advance to and reports
72 /// `APH_E014`.
73 Absent,
74}
75
76/// Signing algorithm a discovered key is pinned to.
77///
78/// The algorithm travels WITH the key rather than being inferred from its
79/// length, so a verifier never guesses which primitive to use.
80#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
81pub enum KeyAlgorithm {
82 /// Ed25519 — the `eddsa-jcs-2022` cryptosuite.
83 Ed25519,
84 /// NIST P-256 — the `ecdsa-jcs-2019` cryptosuite.
85 P256,
86}
87
88impl KeyAlgorithm {
89 /// Parses the `alg` tag of a DNS TXT record (spec §8.4.5).
90 ///
91 /// The tag values are lowercase and closed; anything else is `APH_E010`
92 /// rather than a silent fallback to a default algorithm.
93 pub fn from_dns_tag(tag: &str) -> std::result::Result<Self, crate::errors::AphError> {
94 match tag {
95 "ed25519" => std::result::Result::Ok(Self::Ed25519),
96 "p256" => std::result::Result::Ok(Self::P256),
97 other => std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(other)),
98 }
99 }
100}
101
102/// A notary public key recovered from a discovery mechanism.
103#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq)]
104pub struct NotaryPublicKey {
105 /// Algorithm this key must be used with.
106 pub algorithm: KeyAlgorithm,
107 /// Raw public key bytes (32 for Ed25519, 33 compressed for P-256).
108 pub key_bytes: std::vec::Vec<u8>,
109 /// Key identifier, when the source supplied one. Matched against the
110 /// fragment of `proof.verificationMethod` to pick among several keys.
111 pub kid: std::option::Option<String>,
112}
113
114impl NotaryPublicKey {
115 /// Converts to an Ed25519 verifying key, or fails if this key is a
116 /// different algorithm or not a valid curve point.
117 pub fn to_ed25519(
118 &self,
119 ) -> std::result::Result<ed25519_dalek::VerifyingKey, crate::errors::AphError> {
120 if self.algorithm != KeyAlgorithm::Ed25519 {
121 return std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(
122 "expected ed25519 key",
123 ));
124 }
125 let arr: [u8; 32] = match self.key_bytes.as_slice().try_into() {
126 std::result::Result::Ok(a) => a,
127 std::result::Result::Err(_) => {
128 return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
129 }
130 };
131 match ed25519_dalek::VerifyingKey::from_bytes(&arr) {
132 std::result::Result::Ok(k) => std::result::Result::Ok(k),
133 std::result::Result::Err(_) => {
134 std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature)
135 }
136 }
137 }
138}
139
140/// A parsed `proof.verificationMethod` DID URL.
141#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq)]
142pub struct DidUrl {
143 /// The DID without any fragment, e.g. `did:web:notary.example.com`.
144 pub did: String,
145 /// The fragment after `#`, when present — the key identifier.
146 pub fragment: std::option::Option<String>,
147}
148
149impl DidUrl {
150 /// Splits a DID URL into its DID and fragment.
151 pub fn parse(input: &str) -> Self {
152 match input.split_once('#') {
153 std::option::Option::Some((did, fragment)) => Self {
154 did: String::from(did),
155 fragment: std::option::Option::Some(String::from(fragment)),
156 },
157 std::option::Option::None => Self {
158 did: String::from(input),
159 fragment: std::option::Option::None,
160 },
161 }
162 }
163
164 /// Returns the `did:web` method-specific identifier, if this is a
165 /// `did:web` DID.
166 pub fn web_identifier(&self) -> std::option::Option<&str> {
167 self.did.strip_prefix("did:web:")
168 }
169
170 /// Builds the HTTPS URL of the DID Document for a `did:web` DID
171 /// (spec §8.4.4 step 2).
172 ///
173 /// Per the `did:web` method, colons in the identifier map to path
174 /// segments, and an identifier with no path resolves under
175 /// `/.well-known/`. Each segment is percent-decoded AFTER the split,
176 /// which is how a port written `%3A8443` stays attached to the host
177 /// instead of becoming a path segment.
178 pub fn web_document_url(&self) -> std::option::Option<String> {
179 self.web_url_with_leaf("did.json")
180 }
181
182 /// Builds the HTTPS URL of the DERIVED revocation status endpoint for a
183 /// `did:web` DID (spec §6.3.3.2 step 2).
184 ///
185 /// §6.3.3.2 defines this by reference — "apply §8.4.4 step 2's rule … then
186 /// suffix `/.well-known/aph-status.json` in place of
187 /// `/.well-known/did.json`" — so it shares [`Self::web_document_url`]'s
188 /// derivation exactly and differs only in the last path segment. Sharing
189 /// the body is the point: the two endpoints must agree about the host, or
190 /// the same-origin binding of §6.3.3.2 compares an origin against one the
191 /// same DID would never have produced.
192 ///
193 /// This is the origin a verifier trusts. The `statusListCredential` value
194 /// an envelope carries is NEVER used to derive it — it is only ever bound
195 /// against it by [`same_origin`].
196 pub fn web_status_url(&self) -> std::option::Option<String> {
197 self.web_url_with_leaf(crate::credential_status::STATUS_ENDPOINT_LEAF)
198 }
199
200 /// The shared `did:web` URL derivation: percent-decode-after-split, then
201 /// `leaf` as the final path segment.
202 ///
203 /// One body for both endpoints (§8.4.4 step 2 and §6.3.3.2 step 2). The
204 /// ordering comment below is the whole reason this is not two functions.
205 fn web_url_with_leaf(&self, leaf: &str) -> std::option::Option<String> {
206 let identifier = self.web_identifier()?;
207 // Order matters: split on ':' FIRST, then percent-decode each segment.
208 // Decoding first would turn a `%3A` port separator into a real colon
209 // and split the port off as a path segment.
210 let mut parts = identifier.split(':');
211 let host = percent_decode(parts.next()?);
212 if host.is_empty() {
213 return std::option::Option::None;
214 }
215 let path_segments: std::vec::Vec<String> = parts
216 .filter(|p| !p.is_empty())
217 .map(percent_decode)
218 .collect();
219 if path_segments.is_empty() {
220 std::option::Option::Some(std::format!("https://{}/.well-known/{}", host, leaf))
221 } else {
222 std::option::Option::Some(std::format!(
223 "https://{}/{}/{}",
224 host,
225 path_segments.join("/"),
226 leaf
227 ))
228 }
229 }
230
231 /// Returns the registrable-domain portion used to build the DNS TXT name
232 /// (spec §8.4.5 step 1), i.e. the host without any path segments.
233 pub fn dns_txt_name(&self) -> std::option::Option<String> {
234 let identifier = self.web_identifier()?;
235 let host = percent_decode(identifier.split(':').next()?);
236 // A port is not part of a DNS name, so it is dropped here even though
237 // the HTTPS URL keeps it.
238 let host = host.split(':').next().unwrap_or_default().to_string();
239 if host.is_empty() {
240 return std::option::Option::None;
241 }
242 std::option::Option::Some(std::format!("_aph._notary.{}", host))
243 }
244}
245
246/// The normalized `https:` origin of an absolute URL: `scheme://host:port`
247/// with the default port made explicit.
248///
249/// `None` — meaning "no origin can be compared" — for anything that is not
250/// an absolute `https:` URL this crate is willing to reason about. That
251/// includes an `http:` URL (no TLS anchor, and the TLS name IS the trust
252/// model of §8.4.4), a URL carrying userinfo, and a host with a
253/// percent-escape. All three are refusals rather than best-effort parses:
254/// this function's answers gate a fetch, so an ambiguous URL must fail
255/// closed rather than be normalized into whatever the parser guessed.
256///
257/// No URL crate is introduced, and none is needed: the grammar an APH origin
258/// comparison reads is the authority component of an absolute `https:` URL
259/// and nothing more. Every shape outside that grammar is `None` rather than
260/// a partial parse, which is why the narrowness is safe here and would not
261/// be in a general-purpose URL type.
262pub fn https_origin(url: &str) -> std::option::Option<String> {
263 // The scheme is ASCII case-insensitive per RFC 3986, but the authority is
264 // matched exactly except for the host's case, handled below. Compared over
265 // BYTES: slicing a `&str` by a fixed index would panic on a leading
266 // multi-byte character, and this input is attacker-influenced. A prefix
267 // that matched is all-ASCII, so byte 8 is a char boundary.
268 let rest = match url.as_bytes().get(..8) {
269 std::option::Option::Some(prefix) if prefix.eq_ignore_ascii_case(b"https://") => &url[8..],
270 _ => return std::option::Option::None,
271 };
272 // The authority ends at the first '/', '?' or '#'.
273 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
274 let authority = &rest[..authority_end];
275 if authority.is_empty() {
276 return std::option::Option::None;
277 }
278 // `https://notary.example.com@evil.example/x` has host `evil.example`.
279 // A comparison that skipped this would read the userinfo as the host and
280 // call an attacker's origin the notary's own — the single most likely way
281 // to get §6.3.3.2's binding wrong.
282 if authority.contains('@') {
283 return std::option::Option::None;
284 }
285 // A percent-escape in the authority means the host is not literal, so what
286 // this function would compare is not what a client would connect to.
287 if authority.contains('%') {
288 return std::option::Option::None;
289 }
290 let (host, port) = if let std::option::Option::Some(bracket) = authority.strip_prefix('[') {
291 // IPv6 literal: the host runs to the closing bracket, and any port
292 // follows it. Splitting on ':' without this would cut the address up.
293 let close = bracket.find(']')?;
294 let host = &authority[..close + 2];
295 let remainder = &bracket[close + 1..];
296 match remainder {
297 "" => (host, ""),
298 _ => (host, remainder.strip_prefix(':')?),
299 }
300 } else {
301 match authority.split_once(':') {
302 std::option::Option::Some((host, port)) => {
303 // A second colon in a non-bracketed authority is malformed.
304 if port.contains(':') {
305 return std::option::Option::None;
306 }
307 (host, port)
308 }
309 std::option::Option::None => (authority, ""),
310 }
311 };
312 if host.is_empty() {
313 return std::option::Option::None;
314 }
315 // An empty port component means the scheme default, which is how
316 // `https://host:/x` and `https://host/x` name one origin.
317 let port: u16 = if port.is_empty() {
318 443
319 } else {
320 match port.parse::<u16>() {
321 std::result::Result::Ok(p) => p,
322 std::result::Result::Err(_) => return std::option::Option::None,
323 }
324 };
325 std::option::Option::Some(std::format!("https://{}:{}", host.to_ascii_lowercase(), port))
326}
327
328/// True when two absolute URLs share an origin — identical scheme, host and
329/// port (spec §6.3.3.2).
330///
331/// This is `aph-core`'s only origin comparison, and it is deliberately
332/// public: an adapter guarding a redirect asks exactly the same question a
333/// verifier binding a `statusListCredential` asks, and two implementations
334/// of "same origin" is how one of them ends up wrong. A URL that is not an
335/// absolute `https:` URL is never same-origin with anything, which is how
336/// §6.3.3.2's "MUST use the `https:` scheme" is enforced by the same call
337/// that enforces the binding.
338///
339/// ⛔ IT IS NOT YET THE WORKSPACE'S ONLY ONE, and the earlier wording here
340/// claimed otherwise. `aph-resolver`'s `did:web` fetch adapter still guards
341/// its §8.4.4 redirect with a private `same_origin` over `reqwest::Url`
342/// fields, written before this function existed. The two agree on every
343/// input that adapter can see — both URLs there are always `https:`, and
344/// `reqwest` normalizes host case and the default port — so retiring that
345/// copy onto this call is behaviour-neutral, and it is FILED rather than
346/// done because `aph-resolver` is a separate crate outside this change's
347/// scope. Until it lands, read the sentence above as scoped to `aph-core`,
348/// and do not mint a third.
349pub fn same_origin(a: &str, b: &str) -> bool {
350 match (https_origin(a), https_origin(b)) {
351 (std::option::Option::Some(left), std::option::Option::Some(right)) => left == right,
352 _ => false,
353 }
354}
355
356/// Test-only scaffolding shared by the modules that exercise the async
357/// ports.
358#[cfg(test)]
359pub(crate) mod test_support {
360 /// Drives a future to completion on the calling thread.
361 ///
362 /// `aph-core` carries no async runtime — this crate stays I/O-free and
363 /// dependency-light — so the tests supply the dozen lines of `std` needed
364 /// to poll a future rather than pulling in `tokio` to await a fake that is
365 /// already `Ready`. One copy, in one place, because a second poller is a
366 /// second set of soundness assumptions about waking.
367 pub(crate) fn block_on<F: std::future::Future>(future: F) -> F::Output {
368 struct ThreadWaker(std::thread::Thread);
369 impl std::task::Wake for ThreadWaker {
370 fn wake(self: std::sync::Arc<Self>) {
371 self.0.unpark();
372 }
373 }
374 let waker = std::task::Waker::from(std::sync::Arc::new(ThreadWaker(std::thread::current())));
375 let mut context = std::task::Context::from_waker(&waker);
376 let mut future = std::pin::pin!(future);
377 loop {
378 match std::future::Future::poll(future.as_mut(), &mut context) {
379 std::task::Poll::Ready(value) => return value,
380 std::task::Poll::Pending => std::thread::park(),
381 }
382 }
383 }
384}
385
386/// Decodes `%XX` escapes. Invalid escapes are left verbatim rather than
387/// erroring: the DID is used to build a URL that will simply fail to
388/// resolve, which is a clearer outcome than a parse error here.
389fn percent_decode(input: &str) -> String {
390 let bytes = input.as_bytes();
391 let mut out = String::with_capacity(input.len());
392 let mut i = 0usize;
393 while i < bytes.len() {
394 if bytes[i] == b'%' && i + 2 < bytes.len() {
395 let hex = &input[i + 1..i + 3];
396 // A malformed escape falls through to the literal-copy path below.
397 if let std::result::Result::Ok(b) = u8::from_str_radix(hex, 16) {
398 out.push(b as char);
399 i += 3;
400 continue;
401 }
402 }
403 out.push(bytes[i] as char);
404 i += 1;
405 }
406 out
407}
408
409#[cfg(test)]
410mod tests {
411 #[test]
412 fn did_url_splits_fragment() {
413 // The fragment is the key identifier used to pick among several
414 // published keys; losing it would make rotation ambiguous.
415 let url = super::DidUrl::parse("did:web:notary.example.com#k1");
416 std::assert_eq!(url.did, "did:web:notary.example.com");
417 std::assert_eq!(url.fragment.as_deref(), Some("k1"));
418 }
419
420 #[test]
421 fn did_url_without_fragment_parses() {
422 // A DID with no fragment is legal — it means "the document's only key".
423 let url = super::DidUrl::parse("did:web:notary.example.com");
424 std::assert_eq!(url.fragment, None);
425 }
426
427 #[test]
428 fn plain_did_web_resolves_under_well_known() {
429 // Spec §8.4.4: an identifier with no path segments lives at
430 // /.well-known/did.json.
431 let url = super::DidUrl::parse("did:web:notary.example.com#k1");
432 std::assert_eq!(
433 url.web_document_url().unwrap(),
434 "https://notary.example.com/.well-known/did.json"
435 );
436 }
437
438 #[test]
439 fn path_segments_replace_the_well_known_prefix() {
440 // Colons map to path segments, and a pathful did:web does NOT use
441 // /.well-known — getting this backwards fetches the wrong document.
442 let url = super::DidUrl::parse("did:web:example.com:notaries:alice");
443 std::assert_eq!(
444 url.web_document_url().unwrap(),
445 "https://example.com/notaries/alice/did.json"
446 );
447 }
448
449 #[test]
450 fn percent_encoded_port_survives_colon_mapping() {
451 // A port is written %3A precisely so it is not mistaken for a path
452 // separator; decoding must happen before the split.
453 let url = super::DidUrl::parse("did:web:localhost%3A8443");
454 std::assert_eq!(
455 url.web_document_url().unwrap(),
456 "https://localhost:8443/.well-known/did.json"
457 );
458 }
459
460 #[test]
461 fn dns_txt_name_uses_the_host_only() {
462 // Spec §8.4.5: the TXT record sits at the registrable domain, not at a
463 // name derived from the DID's path segments.
464 let url = super::DidUrl::parse("did:web:example.com:notaries:alice#k1");
465 std::assert_eq!(url.dns_txt_name().unwrap(), "_aph._notary.example.com");
466 }
467
468 #[test]
469 fn dns_name_drops_a_port() {
470 // A port belongs in the HTTPS URL but never in a DNS name; leaving it
471 // in would produce a name that can never resolve.
472 let url = super::DidUrl::parse("did:web:localhost%3A8443");
473 std::assert_eq!(url.dns_txt_name().unwrap(), "_aph._notary.localhost");
474 }
475
476 #[test]
477 fn status_endpoint_mirrors_the_document_url_and_differs_only_in_its_leaf() {
478 // §6.3.3.2 step 2 defines the status endpoint BY REFERENCE to §8.4.4
479 // step 2. Pinning the two side by side is what keeps that reference
480 // true: if one derivation ever drifted, the same-origin binding would
481 // compare the envelope's URL against an origin the DID never produces,
482 // and every status check would refuse a conformant notary.
483 let plain = super::DidUrl::parse("did:web:aph-notary.squillo.com#k1");
484 std::assert_eq!(
485 plain.web_document_url().unwrap(),
486 "https://aph-notary.squillo.com/.well-known/did.json"
487 );
488 std::assert_eq!(
489 plain.web_status_url().unwrap(),
490 "https://aph-notary.squillo.com/.well-known/aph-status.json"
491 );
492 let pathful = super::DidUrl::parse("did:web:example.com:notaries:alice");
493 std::assert_eq!(
494 pathful.web_document_url().unwrap(),
495 "https://example.com/notaries/alice/did.json"
496 );
497 std::assert_eq!(
498 pathful.web_status_url().unwrap(),
499 "https://example.com/notaries/alice/aph-status.json"
500 );
501 // A `%3A` port must stay on the host for the status endpoint too — the
502 // decode-after-split ordering is shared, not re-implemented.
503 let ported = super::DidUrl::parse("did:web:localhost%3A8443");
504 std::assert_eq!(
505 ported.web_status_url().unwrap(),
506 "https://localhost:8443/.well-known/aph-status.json"
507 );
508 }
509
510 #[test]
511 fn same_origin_compares_scheme_host_and_port() {
512 // §6.3.3.2: "identical scheme, host and port". A path difference is
513 // ALLOWED — that is how a notary points at a second list — and every
514 // other difference is a refusal.
515 let derived = "https://aph-notary.squillo.com/.well-known/aph-status.json";
516 std::assert!(super::same_origin(
517 derived,
518 "https://aph-notary.squillo.com/status/list-2.json"
519 ));
520 // Host case and an explicit default port are the same origin; a client
521 // would connect to the same place, and refusing them would refuse a
522 // conformant notary for a spelling.
523 std::assert!(super::same_origin(
524 derived,
525 "https://APH-Notary.Squillo.com:443/.well-known/aph-status.json"
526 ));
527 std::assert!(!super::same_origin(derived, "https://evil.example/x.json"));
528 std::assert!(!super::same_origin(
529 derived,
530 "https://aph-notary.squillo.com:8443/x.json"
531 ));
532 // A subdomain is a different host, however similar it reads.
533 std::assert!(!super::same_origin(
534 derived,
535 "https://aph-notary.squillo.com.evil.example/x.json"
536 ));
537 }
538
539 #[test]
540 fn https_origin_refuses_the_shapes_that_disguise_a_host() {
541 // Each of these has been a real-world origin-check bypass. `None` here
542 // makes `same_origin` false, which under §6.3.3.2 means "reject and do
543 // not fetch" — the fail-closed direction.
544 // Userinfo: the HOST is `evil.example`.
545 std::assert_eq!(
546 super::https_origin("https://aph-notary.squillo.com@evil.example/x"),
547 None
548 );
549 // No TLS anchor at all, and §8.4.4's trust model IS the certificate.
550 std::assert_eq!(super::https_origin("http://aph-notary.squillo.com/x"), None);
551 // A percent-escaped authority is not a literal host.
552 std::assert_eq!(super::https_origin("https://ev%69l.example/x"), None);
553 // Scheme-relative and relative references name no origin.
554 std::assert_eq!(super::https_origin("//aph-notary.squillo.com/x"), None);
555 std::assert_eq!(super::https_origin("/.well-known/aph-status.json"), None);
556 std::assert_eq!(super::https_origin("https://"), None);
557 // A non-numeric or out-of-range port is malformed, not "the default".
558 std::assert_eq!(super::https_origin("https://host:99999/x"), None);
559 std::assert_eq!(super::https_origin("https://host:https/x"), None);
560 // A leading multi-byte character must not panic the byte-index slice.
561 std::assert_eq!(super::https_origin("\u{127}ttps://host/x"), None);
562 }
563
564 #[test]
565 fn https_origin_normalizes_the_default_port_and_ipv6_literals() {
566 // The normalization is what lets `same_origin` be a string comparison.
567 // IPv6 is included because splitting an authority on ':' without
568 // bracket handling cuts an address into a bogus host and port.
569 std::assert_eq!(
570 super::https_origin("https://host/x").unwrap(),
571 "https://host:443"
572 );
573 std::assert_eq!(
574 super::https_origin("https://host:/x").unwrap(),
575 "https://host:443"
576 );
577 std::assert_eq!(
578 super::https_origin("https://[2001:db8::1]:8443/x").unwrap(),
579 "https://[2001:db8::1]:8443"
580 );
581 std::assert!(super::same_origin(
582 "https://[2001:db8::1]/a",
583 "https://[2001:DB8::1]:443/b"
584 ));
585 }
586
587 #[test]
588 fn did_key_has_no_web_or_dns_derivation() {
589 // did:key is resolved offline (§8.4.3); asking for a URL or DNS name
590 // must yield nothing rather than a nonsense host.
591 let url = super::DidUrl::parse("did:key:z6MkfAkf");
592 std::assert_eq!(url.web_document_url(), None);
593 std::assert_eq!(url.dns_txt_name(), None);
594 }
595
596 #[test]
597 fn algorithm_tags_are_a_closed_set() {
598 // An unrecognized alg must be APH_E010, never a default — silently
599 // assuming Ed25519 for an unknown tag would be a downgrade.
600 std::assert_eq!(
601 super::KeyAlgorithm::from_dns_tag("ed25519").unwrap(),
602 super::KeyAlgorithm::Ed25519
603 );
604 std::assert_eq!(
605 super::KeyAlgorithm::from_dns_tag("p256").unwrap(),
606 super::KeyAlgorithm::P256
607 );
608 std::assert_eq!(
609 super::KeyAlgorithm::from_dns_tag("Ed25519").unwrap_err().code(),
610 "APH_E010"
611 );
612 std::assert_eq!(super::KeyAlgorithm::from_dns_tag("rsa").unwrap_err().code(), "APH_E010");
613 }
614
615 #[test]
616 fn to_ed25519_refuses_a_p256_key() {
617 // Algorithm confusion guard: bytes that happen to be the right length
618 // must not be reinterpreted under the wrong primitive.
619 let key = super::NotaryPublicKey {
620 algorithm: super::KeyAlgorithm::P256,
621 key_bytes: std::vec![7u8; 32],
622 kid: None,
623 };
624 std::assert_eq!(key.to_ed25519().unwrap_err().code(), "APH_E010");
625 }
626
627 #[test]
628 fn to_ed25519_rejects_a_wrong_length_key() {
629 // Discovered bytes are attacker-influenced; a short key must error
630 // rather than panic on the fixed-size conversion.
631 let key = super::NotaryPublicKey {
632 algorithm: super::KeyAlgorithm::Ed25519,
633 key_bytes: std::vec![7u8; 16],
634 kid: None,
635 };
636 std::assert!(key.to_ed25519().is_err());
637 }
638}