Skip to main content

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}