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}