Skip to main content

acdp_did/
key.rs

1//! `did:key` — pure, offline DID resolution (no network, no document).
2//!
3//! A `did:key` DID *is* its public key: the method-specific identifier is
4//! the multibase (`z` = base58btc) encoding of the multicodec-prefixed
5//! public key bytes. Resolution is a pure function — no DNS, no HTTPS, no
6//! DID-document fetch, and therefore no SSRF surface and no dependency on
7//! the producer's infrastructure remaining online.
8//!
9//! Supported multicodec prefixes (unsigned-varint encoding):
10//! - `ed25519-pub` (code `0xed`)   → bytes `0xed 0x01`, 32-byte raw key.
11//! - `p256-pub`    (code `0x1200`) → bytes `0x80 0x24`, 33-byte
12//!   SEC1-*compressed* point (per the did:key method spec — NOT the
13//!   65-byte uncompressed form used in `publicKeyJwk`).
14//!
15//! The verification-method fragment convention follows the W3C did:key
16//! method: the key's DID URL is `did:key:z<mb>#z<mb>` — the fragment is
17//! the method-specific identifier itself. There is no `assertionMethod`
18//! check for did:key: the DID is the key, so the key is authorized by
19//! construction.
20//!
21//! Unlike `did:web`, a did:key identity cannot rotate — a new key is a
22//! new identity, and `supersedes` requires the same `agent_id`, so
23//! lineage continuity ends with the key. Conversely, did:key contexts
24//! are immune to the historical-key-validity problem (RFC-ACDP-0008
25//! §9.3) and to domain-lapse hijacking: verification outlives the
26//! producer's infrastructure.
27
28use acdp_primitives::error::AcdpError;
29
30/// Multicodec prefix for `ed25519-pub` (code `0xed`, varint `0xed 0x01`).
31const MULTICODEC_ED25519: [u8; 2] = [0xed, 0x01];
32/// Multicodec prefix for `p256-pub` (code `0x1200`, varint `0x80 0x24`).
33const MULTICODEC_P256: [u8; 2] = [0x80, 0x24];
34
35/// Public key material resolved from a `did:key` DID.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum DidKeyMaterial {
38    /// Raw 32-byte Ed25519 public key.
39    Ed25519([u8; 32]),
40    /// SEC1-compressed P-256 point (33 bytes, leading `0x02`/`0x03`).
41    EcdsaP256([u8; 33]),
42}
43
44impl DidKeyMaterial {
45    /// The ACDP `signature.algorithm` string this key verifies
46    /// (`"ed25519"` or `"ecdsa-p256"`). Used for algorithm-downgrade
47    /// rejection: the body's declared algorithm MUST equal this value.
48    pub fn algorithm(&self) -> &'static str {
49        match self {
50            Self::Ed25519(_) => "ed25519",
51            Self::EcdsaP256(_) => "ecdsa-p256",
52        }
53    }
54}
55
56/// Resolve a `did:key:z…` DID to its public key material — a pure
57/// function, available without any HTTP feature.
58///
59/// Errors are [`AcdpError::KeyResolution`] (wire code
60/// `key_resolution_failed`, permanent): a malformed did:key is a
61/// producer error that no retry will fix.
62pub fn resolve_did_key(did: &str) -> Result<DidKeyMaterial, AcdpError> {
63    let msi = did
64        .strip_prefix("did:key:")
65        .ok_or_else(|| AcdpError::KeyResolution(format!("not a did:key DID: {did}")))?;
66    decode_multibase_key(msi)
67}
68
69/// Decode a multibase-encoded, multicodec-prefixed public key (the
70/// method-specific identifier of a did:key, e.g. `z6Mk…`).
71fn decode_multibase_key(msi: &str) -> Result<DidKeyMaterial, AcdpError> {
72    let rest = msi.strip_prefix('z').ok_or_else(|| {
73        AcdpError::KeyResolution(format!(
74            "did:key requires the 'z' (base58btc) multibase prefix, got '{msi}'"
75        ))
76    })?;
77    let decoded = bs58::decode(rest)
78        .into_vec()
79        .map_err(|e| AcdpError::KeyResolution(format!("did:key base58 decode: {e}")))?;
80
81    match decoded.get(0..2) {
82        Some(p) if p == MULTICODEC_ED25519 => {
83            let key: [u8; 32] = decoded[2..].try_into().map_err(|_| {
84                AcdpError::KeyResolution(format!(
85                    "did:key ed25519 key must be 32 bytes after the multicodec prefix, got {}",
86                    decoded.len().saturating_sub(2)
87                ))
88            })?;
89            Ok(DidKeyMaterial::Ed25519(key))
90        }
91        Some(p) if p == MULTICODEC_P256 => {
92            let key: [u8; 33] = decoded[2..].try_into().map_err(|_| {
93                AcdpError::KeyResolution(format!(
94                    "did:key p256 key must be a 33-byte SEC1-compressed point after the \
95                     multicodec prefix, got {}",
96                    decoded.len().saturating_sub(2)
97                ))
98            })?;
99            if !matches!(key[0], 0x02 | 0x03) {
100                return Err(AcdpError::KeyResolution(
101                    "did:key p256 key must be SEC1-compressed (leading 0x02/0x03)".into(),
102                ));
103            }
104            Ok(DidKeyMaterial::EcdsaP256(key))
105        }
106        _ => Err(AcdpError::KeyResolution(
107            "did:key multicodec prefix is neither ed25519-pub (0xed 0x01) \
108             nor p256-pub (0x80 0x24)"
109                .into(),
110        )),
111    }
112}
113
114/// Validate the `signature.key_id` form for a did:key producer and
115/// return the resolved key material.
116///
117/// Per the did:key method, the only verification method a did:key DID
118/// has is the key itself, addressed as `did:key:z<mb>#z<mb>` — the
119/// fragment MUST equal the method-specific identifier. A fragment
120/// addressing anything else cannot exist in a did:key document and is
121/// rejected with `key_resolution_failed`.
122pub fn resolve_did_key_url(key_id: &str) -> Result<DidKeyMaterial, AcdpError> {
123    let (did_part, fragment) = key_id
124        .split_once('#')
125        .ok_or_else(|| AcdpError::KeyResolution(format!("key_id '{key_id}' has no '#fragment'")))?;
126    let msi = did_part
127        .strip_prefix("did:key:")
128        .ok_or_else(|| AcdpError::KeyResolution(format!("not a did:key DID URL: {key_id}")))?;
129    if fragment != msi {
130        return Err(AcdpError::KeyResolution(format!(
131            "did:key fragment '#{fragment}' must equal the method-specific identifier \
132             '{msi}' (the did:key document's only verification method is the key itself)"
133        )));
134    }
135    decode_multibase_key(msi)
136}
137
138/// Encode a raw 32-byte Ed25519 public key as a `did:key` DID.
139pub fn did_key_from_ed25519(public_key: &[u8; 32]) -> String {
140    let mut prefixed = Vec::with_capacity(2 + 32);
141    prefixed.extend_from_slice(&MULTICODEC_ED25519);
142    prefixed.extend_from_slice(public_key);
143    format!("did:key:z{}", bs58::encode(&prefixed).into_string())
144}
145
146/// Encode a SEC1 P-256 public key (compressed 33-byte or uncompressed
147/// 65-byte form) as a `did:key` DID. Uncompressed input is compressed
148/// first, per the did:key method spec.
149pub fn did_key_from_p256_sec1(sec1: &[u8]) -> Result<String, AcdpError> {
150    let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1)
151        .map_err(|e| AcdpError::KeyResolution(format!("p256 SEC1 parse: {e}")))?;
152    let compressed = vk.to_encoded_point(true);
153    let mut prefixed = Vec::with_capacity(2 + 33);
154    prefixed.extend_from_slice(&MULTICODEC_P256);
155    prefixed.extend_from_slice(compressed.as_bytes());
156    Ok(format!(
157        "did:key:z{}",
158        bs58::encode(&prefixed).into_string()
159    ))
160}
161
162/// The canonical `signature.key_id` DID URL for a did:key DID:
163/// `did:key:z<mb>#z<mb>` (fragment = method-specific identifier).
164pub fn did_key_url(did: &str) -> Result<String, AcdpError> {
165    let msi = did
166        .strip_prefix("did:key:")
167        .ok_or_else(|| AcdpError::KeyResolution(format!("not a did:key DID: {did}")))?;
168    Ok(format!("{did}#{msi}"))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    /// Ed25519 public key of the all-zero seed (sig-001 test key).
176    const TEST_PUB_HEX: &str = "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29";
177
178    fn test_pub() -> [u8; 32] {
179        hex::decode(TEST_PUB_HEX).unwrap().try_into().unwrap()
180    }
181
182    #[test]
183    fn ed25519_round_trip() {
184        let did = did_key_from_ed25519(&test_pub());
185        assert!(did.starts_with("did:key:z"));
186        match resolve_did_key(&did).unwrap() {
187            DidKeyMaterial::Ed25519(k) => assert_eq!(k, test_pub()),
188            other => panic!("expected Ed25519, got {other:?}"),
189        }
190    }
191
192    #[test]
193    fn p256_round_trip_compresses_uncompressed_input() {
194        let key = acdp_crypto::sign::P256SigningKey::generate();
195        let did = did_key_from_p256_sec1(&key.verifying_key_sec1()).unwrap();
196        match resolve_did_key(&did).unwrap() {
197            DidKeyMaterial::EcdsaP256(k) => {
198                assert_eq!(k.len(), 33);
199                assert!(matches!(k[0], 0x02 | 0x03));
200                // Decompressing must give back the original point.
201                let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(&k).unwrap();
202                assert_eq!(
203                    vk.to_encoded_point(false).as_bytes(),
204                    key.verifying_key_sec1().as_slice()
205                );
206            }
207            other => panic!("expected EcdsaP256, got {other:?}"),
208        }
209    }
210
211    #[test]
212    fn key_url_fragment_must_equal_msi() {
213        let did = did_key_from_ed25519(&test_pub());
214        let url = did_key_url(&did).unwrap();
215        resolve_did_key_url(&url).unwrap();
216
217        // Wrong fragment → rejected.
218        let bad = format!("{did}#key-1");
219        let err = resolve_did_key_url(&bad).unwrap_err();
220        assert!(matches!(err, AcdpError::KeyResolution(_)), "got {err:?}");
221
222        // No fragment → rejected.
223        let err = resolve_did_key_url(&did).unwrap_err();
224        assert!(matches!(err, AcdpError::KeyResolution(_)), "got {err:?}");
225    }
226
227    #[test]
228    fn rejects_unknown_multicodec() {
229        // secp256k1-pub (0xe7 0x01) is not an ACDP algorithm.
230        let mut prefixed = vec![0xe7, 0x01];
231        prefixed.extend_from_slice(&[0u8; 33]);
232        let did = format!("did:key:z{}", bs58::encode(&prefixed).into_string());
233        let err = resolve_did_key(&did).unwrap_err();
234        assert!(matches!(err, AcdpError::KeyResolution(_)), "got {err:?}");
235    }
236
237    #[test]
238    fn rejects_non_z_multibase_and_garbage() {
239        for bad in ["did:key:uAAAA", "did:key:z!!!not-base58!!!", "did:web:x"] {
240            assert!(
241                resolve_did_key(bad).is_err(),
242                "'{bad}' must fail did:key resolution"
243            );
244        }
245    }
246
247    #[test]
248    fn rejects_wrong_length_ed25519() {
249        let mut prefixed = MULTICODEC_ED25519.to_vec();
250        prefixed.extend_from_slice(&[0u8; 31]); // 31, not 32
251        let did = format!("did:key:z{}", bs58::encode(&prefixed).into_string());
252        assert!(resolve_did_key(&did).is_err());
253    }
254
255    #[test]
256    fn rejects_uncompressed_p256_payload() {
257        // 65-byte uncompressed point inside the multicodec wrapper is
258        // not did:key-conformant (the spec mandates compressed).
259        let key = acdp_crypto::sign::P256SigningKey::generate();
260        let mut prefixed = MULTICODEC_P256.to_vec();
261        prefixed.extend_from_slice(&key.verifying_key_sec1());
262        let did = format!("did:key:z{}", bs58::encode(&prefixed).into_string());
263        assert!(resolve_did_key(&did).is_err());
264    }
265
266    #[test]
267    fn algorithm_strings() {
268        assert_eq!(DidKeyMaterial::Ed25519([0; 32]).algorithm(), "ed25519");
269        assert_eq!(DidKeyMaterial::EcdsaP256([2; 33]).algorithm(), "ecdsa-p256");
270    }
271}