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