Skip to main content

substrate/crypto/
hub.rs

1//! CMN hub subdomain helpers.
2//!
3//! These helpers encode a CMN public key into a DNS-safe subdomain and recover
4//! the public key from that subdomain without a database lookup.
5
6use anyhow::{anyhow, Result};
7
8use super::{format_key, parse_key, KeyAlgorithm};
9
10/// Compute a DNS-safe cmnhub subdomain from a public key string.
11///
12/// Input: `"ed25519.<base58>"` (the standard CMN public key format).
13/// Output: `"ed-<base32-lowercase-nopad>"` (55 chars, fits DNS 63-char label limit).
14///
15/// The subdomain is the raw pubkey bytes encoded directly as base32 lowercase
16/// without padding, prefixed with `ed-` (ed25519). No hashing — the subdomain
17/// IS the public key in a DNS-safe encoding. Base32 is used because DNS labels
18/// are case-insensitive (RFC 4343), ruling out base58/base64.
19///
20/// The pubkey can be recovered from the subdomain by stripping the `ed-` prefix
21/// and base32-decoding, enabling signature verification without a database lookup.
22///
23/// # Examples
24/// ```
25/// use substrate::crypto::hub::compute_hub_subdomain;
26///
27/// let sub = compute_hub_subdomain("ed25519.2p3NPZceQ6njbPg8aMFsEynX3Cmv6uCt1XMGHhPcL4AT").unwrap();
28/// assert!(sub.starts_with("ed-"));
29/// assert_eq!(sub.len(), 55);
30/// assert!(sub.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
31/// ```
32pub fn compute_hub_subdomain(public_key: &str) -> Result<String> {
33    let key = parse_key(public_key)?;
34
35    let b32 = data_encoding::BASE32_NOPAD
36        .encode(&key.bytes)
37        .to_ascii_lowercase();
38
39    Ok(format!("ed-{}", b32))
40}
41
42/// Recover the ed25519 public key from a hub subdomain.
43///
44/// Input: `"ed-<base32-lowercase-nopad>"` (as produced by `compute_hub_subdomain`).
45/// Output: `"ed25519.<base58>"` (standard CMN public key format).
46///
47/// # Examples
48/// ```
49/// use substrate::crypto::hub::{compute_hub_subdomain, recover_pubkey_from_subdomain};
50///
51/// let key = "ed25519.2p3NPZceQ6njbPg8aMFsEynX3Cmv6uCt1XMGHhPcL4AT";
52/// let sub = compute_hub_subdomain(key).unwrap();
53/// let recovered = recover_pubkey_from_subdomain(&sub).unwrap();
54/// assert_eq!(recovered, key);
55/// ```
56pub fn recover_pubkey_from_subdomain(subdomain: &str) -> Result<String> {
57    let b32 = subdomain
58        .strip_prefix("ed-")
59        .ok_or_else(|| anyhow!("Subdomain must start with 'ed-'"))?;
60
61    let key_bytes = data_encoding::BASE32_NOPAD
62        .decode(b32.to_ascii_uppercase().as_bytes())
63        .map_err(|e| anyhow!("Invalid base32 in subdomain: {}", e))?;
64    if key_bytes.len() != 32 {
65        return Err(anyhow!(
66            "Invalid ed25519 public key length in subdomain: expected 32 bytes, got {}",
67            key_bytes.len()
68        ));
69    }
70
71    Ok(format_key(KeyAlgorithm::Ed25519, &key_bytes))
72}
73
74#[cfg(test)]
75#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
76mod tests {
77
78    use super::*;
79
80    #[test]
81    fn test_compute_hub_subdomain_shape() {
82        let sub =
83            compute_hub_subdomain("ed25519.2p3NPZceQ6njbPg8aMFsEynX3Cmv6uCt1XMGHhPcL4AT").unwrap();
84        assert!(sub.starts_with("ed-"));
85        assert_eq!(sub.len(), 55);
86        assert!(sub
87            .chars()
88            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
89    }
90
91    #[test]
92    fn test_recover_pubkey_from_subdomain_roundtrip() {
93        let key = "ed25519.2p3NPZceQ6njbPg8aMFsEynX3Cmv6uCt1XMGHhPcL4AT";
94        let sub = compute_hub_subdomain(key).unwrap();
95        let recovered = recover_pubkey_from_subdomain(&sub).unwrap();
96        assert_eq!(recovered, key);
97    }
98
99    #[test]
100    fn test_recover_pubkey_from_subdomain_rejects_invalid_prefix() {
101        assert!(recover_pubkey_from_subdomain("xx-invalid").is_err());
102    }
103}