Skip to main content

aph_core/crypto/
did_key.rs

1//! `did:key` — self-describing notary identifiers.
2//!
3//! `did:key` is the first and preferred key-discovery mechanism (spec §8.4.6)
4//! because it needs no network at all: the public key IS the identifier. A
5//! verifier that receives `issuer: "did:key:z6Mk..."` can decode the key from
6//! that string and check the signature entirely offline.
7//!
8//! The identifier is a multicodec-prefixed public key in multibase base58btc
9//! (spec §8.4.3). The prefixes APH uses are `0xed01` for Ed25519 and `0x1200`
10//! for P-256, both varint-encoded — `0x1200` becomes the two bytes
11//! `0x80 0x24`, which is why the constants below are not simply the codec
12//! numbers written out.
13
14/// Multicodec varint prefix for an Ed25519 public key (`0xed01`).
15const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
16/// Multicodec varint prefix for a P-256 public key (`0x1200`).
17const P256_MULTICODEC: [u8; 2] = [0x80, 0x24];
18
19/// The `did:key:` URI scheme prefix.
20const DID_KEY_PREFIX: &str = "did:key:";
21
22/// A public key decoded from a `did:key` identifier.
23#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq)]
24pub enum DecodedDidKey {
25  /// Ed25519 verifying key — the `eddsa-jcs-2022` cryptosuite.
26  Ed25519(std::boxed::Box<ed25519_dalek::VerifyingKey>),
27  /// Compressed P-256 point — the `ecdsa-jcs-2019` cryptosuite. Returned as
28  /// raw bytes so callers that do not need ES256 pay nothing to parse it.
29  P256(std::vec::Vec<u8>),
30}
31
32/// Builds the `did:key` identifier for an Ed25519 public key.
33///
34/// This is how a notary derives the DID it publishes as `issuer`, so it must
35/// agree exactly with what verifiers decode.
36pub fn encode_ed25519(key: &ed25519_dalek::VerifyingKey) -> String {
37  let mut payload = std::vec::Vec::with_capacity(2 + 32);
38  payload.extend_from_slice(&ED25519_MULTICODEC);
39  payload.extend_from_slice(key.as_bytes());
40  std::format!("{}{}", DID_KEY_PREFIX, super::multibase::base58btc_encode(&payload))
41}
42
43/// Builds the `did:key` identifier for a P-256 public key.
44///
45/// The point is COMPRESSED (33 bytes, `0x02`/`0x03` parity prefix), which is
46/// what [`decode`] expects and what the `0x1200` multicodec names. The
47/// uncompressed form would decode to a different identifier for the same key,
48/// so an issuer and a verifier that disagreed here would never resolve each
49/// other's DIDs.
50///
51/// The Ed25519 sibling is [`encode_ed25519`]; both exist so a notary derives
52/// the DID it publishes rather than transcribing one.
53pub fn encode_p256(key: &p256::ecdsa::VerifyingKey) -> String {
54  let point = key.to_encoded_point(true);
55  let mut payload = std::vec::Vec::with_capacity(2 + 33);
56  payload.extend_from_slice(&P256_MULTICODEC);
57  payload.extend_from_slice(point.as_bytes());
58  std::format!("{}{}", DID_KEY_PREFIX, super::multibase::base58btc_encode(&payload))
59}
60
61/// Decodes a `did:key` identifier into the public key it names.
62///
63/// Every failure path returns `APH_E001`: a verifier that cannot decode the
64/// issuer's key must refuse the envelope, never fall through to accepting it.
65pub fn decode(did: &str) -> std::result::Result<DecodedDidKey, crate::errors::AphError> {
66  let encoded = match did.strip_prefix(DID_KEY_PREFIX) {
67    std::option::Option::Some(rest) => rest,
68    std::option::Option::None => {
69      return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
70    }
71  };
72  decode_multibase_key(encoded)
73}
74
75/// Decodes a multicodec-prefixed public key in multibase base58btc.
76///
77/// This is the payload half of a `did:key` identifier, and is also exactly
78/// what a DID Document's `publicKeyMultibase` field carries — so `did:web`
79/// discovery reuses this decoder rather than reimplementing it.
80pub fn decode_multibase_key(
81  encoded: &str,
82) -> std::result::Result<DecodedDidKey, crate::errors::AphError> {
83  let bytes = super::multibase::base58btc_decode(encoded)?;
84  if bytes.len() < 3 {
85    return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
86  }
87  let (prefix, key_bytes) = bytes.split_at(2);
88
89  if prefix == ED25519_MULTICODEC {
90    let arr: [u8; 32] = match key_bytes.try_into() {
91      std::result::Result::Ok(a) => a,
92      // Wrong length for the declared codec: the identifier is malformed.
93      std::result::Result::Err(_) => {
94        return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
95      }
96    };
97    return match ed25519_dalek::VerifyingKey::from_bytes(&arr) {
98      std::result::Result::Ok(k) => {
99        std::result::Result::Ok(DecodedDidKey::Ed25519(std::boxed::Box::new(k)))
100      }
101      // Well-formed length but not a valid curve point.
102      std::result::Result::Err(_) => {
103        std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature)
104      }
105    };
106  }
107
108  if prefix == P256_MULTICODEC {
109    // Compressed SEC1 point: 33 bytes with a 0x02/0x03 parity byte.
110    if key_bytes.len() != 33 {
111      return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
112    }
113    return std::result::Result::Ok(DecodedDidKey::P256(key_bytes.to_vec()));
114  }
115
116  // A codec APH does not define. Refuse rather than guess at the algorithm.
117  std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(std::format!(
118    "did:key multicodec 0x{:02x}{:02x}",
119    prefix[0],
120    prefix[1]
121  )))
122}
123
124#[cfg(test)]
125mod tests {
126  fn sample_key() -> ed25519_dalek::VerifyingKey {
127    let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
128    sk.verifying_key()
129  }
130
131  #[test]
132  fn encode_decode_round_trip() {
133    // A notary derives its issuer DID from its key; a verifier reverses that
134    // derivation. If the two disagree, no self-issued envelope verifies.
135    let key = sample_key();
136    let did = super::encode_ed25519(&key);
137    match super::decode(&did).unwrap() {
138      super::DecodedDidKey::Ed25519(decoded) => std::assert_eq!(*decoded, key),
139      other => std::panic!("expected Ed25519, got {:?}", other),
140    }
141  }
142
143  #[test]
144  fn ed25519_dids_use_the_z6mk_prefix() {
145    // The 0xed01 multicodec plus base58btc always yields "z6Mk..." for a
146    // 32-byte key. Pinning it catches a wrong multicodec prefix, which would
147    // otherwise produce identifiers no other implementation can resolve.
148    let did = super::encode_ed25519(&sample_key());
149    std::assert!(did.starts_with("did:key:z6Mk"), "got {}", did);
150  }
151
152  #[test]
153  fn published_example_issuer_decodes() {
154    // The issuer DID from the repository's published example envelopes must
155    // decode with this implementation — otherwise the examples we hand to
156    // implementers cannot be verified by our own reference code.
157    let did = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV";
158    std::assert!(matches!(
159      super::decode(did).unwrap(),
160      super::DecodedDidKey::Ed25519(_)
161    ));
162  }
163
164  #[test]
165  fn p256_encode_decode_round_trip() {
166    // The ES256 half of the same weld: a notary on a P-256 device key derives
167    // its issuer DID here, and a verifier reverses that derivation. The
168    // COMPRESSED point is the load-bearing detail — the uncompressed form is
169    // 65 bytes and would produce a different identifier for the same key,
170    // which no other implementation would resolve.
171    let signing = p256::ecdsa::SigningKey::from_slice(&[
172      0xc9, 0xaf, 0xa9, 0xd8, 0x45, 0xba, 0x75, 0x16, 0x6b, 0x5c, 0x21, 0x57, 0x67, 0xb1, 0xd6,
173      0x93, 0x4e, 0x50, 0xc3, 0xdb, 0x36, 0xe8, 0x9b, 0x12, 0x7b, 0x8a, 0x62, 0x2b, 0x12, 0x0f,
174      0x67, 0x21,
175    ])
176    .expect("the RFC 6979 A.2.5 sample key is a valid P-256 scalar");
177    let key = *signing.verifying_key();
178    let did = super::encode_p256(&key);
179    // The 0x1200 multicodec plus base58btc always yields "zDn..." for a
180    // compressed P-256 point; pinning it catches a wrong prefix, which would
181    // otherwise produce identifiers no other implementation can resolve.
182    std::assert!(did.starts_with("did:key:zDn"), "got {}", did);
183    match super::decode(&did).unwrap() {
184      super::DecodedDidKey::P256(bytes) => {
185        std::assert_eq!(bytes, key.to_encoded_point(true).as_bytes().to_vec());
186      }
187      other => std::panic!("expected P256, got {:?}", other),
188    }
189  }
190
191  #[test]
192  fn non_did_key_input_is_rejected() {
193    // did:web and DNS discovery are different mechanisms with their own
194    // resolvers; this decoder must not silently accept their identifiers.
195    std::assert!(super::decode("did:web:notary.example").is_err());
196    std::assert!(super::decode("z6MkfAkf").is_err());
197  }
198
199  #[test]
200  fn unknown_multicodec_reports_unsupported_algorithm() {
201    // A key type APH does not define must surface as APH_E010 rather than a
202    // generic signature failure, so an operator can tell the two apart.
203    let payload = super::super::multibase::base58btc_encode(&[0x99u8, 0x01, 1, 2, 3]);
204    let err = super::decode(&std::format!("did:key:{}", payload)).unwrap_err();
205    std::assert_eq!(err.code(), "APH_E010");
206  }
207
208  #[test]
209  fn truncated_key_is_rejected_not_panicking() {
210    // Attacker-controlled input: a correct multicodec with a short key must
211    // error rather than panic on the fixed-size array conversion.
212    let payload = super::super::multibase::base58btc_encode(&[0xed, 0x01, 1, 2, 3]);
213    std::assert!(super::decode(&std::format!("did:key:{}", payload)).is_err());
214  }
215}