Skip to main content

auths_keri/
did_webs.rs

1//! `did:webs` DID-document projection of a resolved KERI key-state.
2//!
3//! `did:webs` anchors a KERI AID into a **web-resolvable** DID document so a
4//! standard DID resolver can verify the identifier without speaking KERI itself.
5//! The document is *derived*, not authored: every field comes from replaying the
6//! KEL into a [`KeyState`], so the verification material is exactly the AID's
7//! current signing keys. The KEL remains the source of truth; this is its
8//! projection into the DID-core data model.
9//!
10//! Wire shape (the resolved `didDocument`, ToIP did:webs method):
11//! `{id, verificationMethod, service, alsoKnownAs}` — field order and labels
12//! match the reference resolver (`did-webs-resolver`'s `gen_did_document`), so a
13//! document auths emits reads in a stock did:webs/DID-core resolver.
14//!
15//! - `id` is `did:webs:<domain>:<aid>` (the AID is the resolved prefix).
16//! - each current signing key becomes one `JsonWebKey` verification method whose
17//!   fragment is the key's own CESR value (`#DAAB…`), controller is the document
18//!   `id`, and `publicKeyJwk` carries the curve-correct JWK (`OKP`/`Ed25519` for
19//!   Ed25519, `EC`/`P-256` for P-256) — the byte-exact form the reference emits.
20//! - `alsoKnownAs` carries the `did:keri:<aid>` equivalent, the cross-method link
21//!   that lets a resolver fall back to native KERI resolution.
22//!
23//! It is a *parsed* type: building one from a [`KeyState`] cannot fail to be
24//! well-formed (a resolved key-state already names valid current keys), and a
25//! verification method is constructed only from a decoded [`KeriPublicKey`], so a
26//! malformed key is rejected at the boundary rather than serialized into a
27//! document.
28
29use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
30use serde::{Deserialize, Serialize};
31
32use crate::keys::{KeriDecodeError, KeriPublicKey};
33use crate::state::KeyState;
34use crate::types::CesrKey;
35
36/// A public key projected into the JOSE JWK shape a DID-core `publicKeyJwk`
37/// carries. Curve-tagged so a resolver picks the right verification algorithm:
38/// Ed25519 is an `OKP` key (`x` only), P-256 is an `EC` key (`x` and `y`).
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(tag = "kty")]
41pub enum PublicKeyJwk {
42    /// Edwards-curve octet key pair (Ed25519): `{kty:"OKP", crv:"Ed25519", x}`.
43    #[serde(rename = "OKP")]
44    Okp {
45        /// JWK key id — the key's CESR-qualified value (`kid`).
46        kid: String,
47        /// Curve name — always `"Ed25519"` for this variant.
48        crv: String,
49        /// base64url(no-pad) of the 32 raw public-key bytes.
50        x: String,
51    },
52    /// Elliptic-curve key (P-256): `{kty:"EC", crv:"P-256", x, y}`.
53    #[serde(rename = "EC")]
54    Ec {
55        /// JWK key id — the key's CESR-qualified value (`kid`).
56        kid: String,
57        /// Curve name — always `"P-256"` for this variant.
58        crv: String,
59        /// base64url(no-pad) of the 32-byte affine x-coordinate.
60        x: String,
61        /// base64url(no-pad) of the 32-byte affine y-coordinate.
62        y: String,
63    },
64}
65
66impl PublicKeyJwk {
67    /// Project a decoded KERI public key into its JWK, tagged `kid` with the
68    /// key's own CESR value.
69    ///
70    /// Ed25519 maps to an `OKP` key over the 32 raw bytes; P-256 maps to an `EC`
71    /// key whose `x`/`y` are the affine coordinates recovered by decompressing the
72    /// SEC1 point. Returns [`KeriDecodeError::DecodeError`] only if a P-256 point
73    /// fails to decompress (not a valid curve point) — Ed25519 is infallible.
74    pub fn from_key(key: &KeriPublicKey, kid: &str) -> Result<Self, KeriDecodeError> {
75        match key {
76            KeriPublicKey::Ed25519 { key: raw, .. } => Ok(Self::Okp {
77                kid: kid.to_string(),
78                crv: "Ed25519".to_string(),
79                x: URL_SAFE_NO_PAD.encode(raw),
80            }),
81            KeriPublicKey::P256 {
82                key: compressed, ..
83            } => {
84                use p256::elliptic_curve::sec1::ToEncodedPoint;
85                // Decompress the SEC1 point and re-encode uncompressed to read
86                // both affine coordinates the EC JWK needs.
87                let pk = p256::PublicKey::from_sec1_bytes(compressed).map_err(|e| {
88                    KeriDecodeError::DecodeError(format!("P-256 point decode failed: {e}"))
89                })?;
90                let uncompressed = pk.to_encoded_point(false);
91                let x = uncompressed.x().ok_or_else(|| {
92                    KeriDecodeError::DecodeError("P-256 point has no x-coordinate".to_string())
93                })?;
94                let y = uncompressed.y().ok_or_else(|| {
95                    KeriDecodeError::DecodeError("P-256 point has no y-coordinate".to_string())
96                })?;
97                Ok(Self::Ec {
98                    kid: kid.to_string(),
99                    crv: "P-256".to_string(),
100                    x: URL_SAFE_NO_PAD.encode(x),
101                    y: URL_SAFE_NO_PAD.encode(y),
102                })
103            }
104        }
105    }
106}
107
108/// A single DID-core verification method projecting one current signing key.
109///
110/// The fragment (`id`) is the key's own CESR value, so the verification method is
111/// self-identifying across a rotation: a resolver references the exact key that
112/// signed, not a positional `#key-0` that shifts when keys rotate.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct VerificationMethod {
115    /// DID-relative fragment `#<key-cesr>` (the key's own CESR value).
116    pub id: String,
117    /// Verification-method type — `"JsonWebKey"` (the curve lives in `publicKeyJwk`).
118    #[serde(rename = "type")]
119    pub type_: String,
120    /// The controlling DID (the document `id`).
121    pub controller: String,
122    /// The public key in JWK form.
123    #[serde(rename = "publicKeyJwk")]
124    pub public_key_jwk: PublicKeyJwk,
125}
126
127/// The resolved `did:webs` DID document for a KERI AID.
128///
129/// Field order and labels match the ToIP did:webs reference resolver's
130/// `gen_did_document` (`{id, verificationMethod, service, alsoKnownAs}`), so the
131/// emitted JSON reads byte-compatibly in a stock did:webs/DID-core resolver.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct DidWebsDocument {
134    /// The DID this document describes: `did:webs:<domain>:<aid>`.
135    pub id: String,
136    /// One verification method per current signing key.
137    #[serde(rename = "verificationMethod")]
138    pub verification_method: Vec<VerificationMethod>,
139    /// Service endpoints (KERI agent/witness URLs). Empty for a KEL-only
140    /// projection that has no live endpoint advertisement.
141    pub service: Vec<serde_json::Value>,
142    /// Designated equivalent identifiers — carries the `did:keri:<aid>` link so a
143    /// resolver can fall back to native KERI resolution of the same AID.
144    #[serde(rename = "alsoKnownAs")]
145    pub also_known_as: Vec<String>,
146}
147
148impl DidWebsDocument {
149    /// Build a `did:webs` DID document by projecting a resolved key-state onto the
150    /// given web `domain`.
151    ///
152    /// `domain` is the host (and optional `:port`/path) the document will be
153    /// served under; the AID is `state.prefix`. Every current signing key in
154    /// `state` becomes one verification method. Returns
155    /// [`KeriDecodeError`] only if a current key is undecodable or (for P-256) not
156    /// a valid curve point — invalidity caught at the boundary, never serialized.
157    ///
158    /// Args:
159    /// * `state`: The resolved current [`KeyState`] (from KEL replay).
160    /// * `domain`: The web domain/host the `did:webs` is anchored at.
161    pub fn from_key_state(state: &KeyState, domain: &str) -> Result<Self, KeriDecodeError> {
162        let aid = state.prefix.as_str();
163        let id = format!("did:webs:{domain}:{aid}");
164
165        let mut verification_method = Vec::with_capacity(state.current_keys.len());
166        for cesr_key in &state.current_keys {
167            verification_method.push(verification_method_for(cesr_key, &id)?);
168        }
169
170        Ok(Self {
171            id,
172            verification_method,
173            service: Vec::new(),
174            also_known_as: vec![format!("did:keri:{aid}")],
175        })
176    }
177}
178
179/// Build one verification method from a current key's CESR string, controlled by
180/// `did`. The key is decoded first (parse, don't validate), so the JWK is built
181/// only from a known curve and valid bytes.
182fn verification_method_for(
183    cesr_key: &CesrKey,
184    did: &str,
185) -> Result<VerificationMethod, KeriDecodeError> {
186    let kid = cesr_key.as_str();
187    let key = KeriPublicKey::parse(kid)?;
188    Ok(VerificationMethod {
189        id: format!("#{kid}"),
190        type_: "JsonWebKey".to_string(),
191        controller: did.to_string(),
192        public_key_jwk: PublicKeyJwk::from_key(&key, kid)?,
193    })
194}
195
196#[cfg(test)]
197#[allow(clippy::unwrap_used, clippy::expect_used)]
198mod tests {
199    use super::*;
200    use crate::types::{Prefix, Said, Threshold};
201
202    /// A single-key Ed25519 key-state at the given AID/key.
203    fn ed25519_state(aid: &str, key_cesr: &str) -> KeyState {
204        KeyState::from_inception(
205            Prefix::new_unchecked(aid.to_string()),
206            vec![CesrKey::new_unchecked(key_cesr.to_string())],
207            vec![Said::new_unchecked("ENext0".to_string())],
208            Threshold::Simple(1),
209            Threshold::Simple(1),
210            Said::new_unchecked(aid.to_string()),
211            vec![],
212            Threshold::Simple(0),
213            vec![],
214        )
215    }
216
217    /// The CESR-qualified Ed25519 verkey over `raw`.
218    fn ed25519_cesr(raw: &[u8; 32]) -> String {
219        KeriPublicKey::ed25519(raw).unwrap().to_qb64().unwrap()
220    }
221
222    /// The CESR-qualified P-256 verkey over a real keypair's compressed point.
223    fn p256_cesr() -> (String, [u8; 33]) {
224        use p256::elliptic_curve::sec1::ToEncodedPoint;
225        // Deterministic non-identity scalar → a valid curve point.
226        let sk = p256::SecretKey::from_slice(&[
227            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
228            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
229            0x1d, 0x1e, 0x1f, 0x20,
230        ])
231        .unwrap();
232        let pt = sk.public_key().to_encoded_point(true);
233        let mut compressed = [0u8; 33];
234        compressed.copy_from_slice(pt.as_bytes());
235        let cesr = KeriPublicKey::P256 {
236            key: compressed,
237            transferable: true,
238        }
239        .to_qb64()
240        .unwrap();
241        (cesr, compressed)
242    }
243
244    #[test]
245    fn document_has_canonical_field_order() {
246        let key = ed25519_cesr(&[3u8; 32]);
247        let state = ed25519_state("EAid000000000000000000000000000000000000000", &key);
248        let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap();
249
250        let json = serde_json::to_value(&doc).unwrap();
251        let keys: Vec<&str> = json
252            .as_object()
253            .unwrap()
254            .keys()
255            .map(String::as_str)
256            .collect();
257        // Reference resolver `gen_did_document` order: id, verificationMethod, service, alsoKnownAs.
258        assert_eq!(
259            keys,
260            vec!["id", "verificationMethod", "service", "alsoKnownAs"]
261        );
262    }
263
264    #[test]
265    fn ed25519_verification_method_matches_reference_shape() {
266        let key = ed25519_cesr(&[7u8; 32]);
267        let state = ed25519_state("EAid000000000000000000000000000000000000000", &key);
268        let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap();
269
270        assert_eq!(
271            doc.id,
272            "did:webs:example.com:EAid000000000000000000000000000000000000000"
273        );
274        assert_eq!(
275            doc.also_known_as,
276            vec!["did:keri:EAid000000000000000000000000000000000000000"]
277        );
278        assert!(doc.service.is_empty());
279
280        let vm = &doc.verification_method[0];
281        // Fragment is the key's OWN cesr value, not a positional #key-0.
282        assert_eq!(vm.id, format!("#{key}"));
283        assert_eq!(vm.type_, "JsonWebKey");
284        assert_eq!(vm.controller, doc.id);
285        match &vm.public_key_jwk {
286            PublicKeyJwk::Okp { kid, crv, x } => {
287                assert_eq!(kid, &key);
288                assert_eq!(crv, "Ed25519");
289                // x is base64url(no-pad) of the 32 raw bytes.
290                assert_eq!(x, &URL_SAFE_NO_PAD.encode([7u8; 32]));
291            }
292            other => panic!("expected OKP JWK, got {other:?}"),
293        }
294    }
295
296    #[test]
297    fn ed25519_jwk_serializes_kty_first() {
298        // `#[serde(tag = "kty")]` puts kty at the front, then the variant fields —
299        // {kty, kid, crv, x}, the reference publicKeyJwk shape.
300        let key = ed25519_cesr(&[1u8; 32]);
301        let state = ed25519_state("EAid000000000000000000000000000000000000000", &key);
302        let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap();
303        let jwk = serde_json::to_value(&doc.verification_method[0].public_key_jwk).unwrap();
304        let labels: Vec<&str> = jwk
305            .as_object()
306            .unwrap()
307            .keys()
308            .map(String::as_str)
309            .collect();
310        assert_eq!(labels, vec!["kty", "kid", "crv", "x"]);
311        assert_eq!(jwk["kty"], "OKP");
312        assert_eq!(jwk["crv"], "Ed25519");
313    }
314
315    #[test]
316    fn p256_verification_method_emits_ec_jwk_with_x_and_y() {
317        let (key, compressed) = p256_cesr();
318        let state = ed25519_state("EAidP256000000000000000000000000000000000000", &key);
319        let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap();
320
321        let vm = &doc.verification_method[0];
322        assert_eq!(vm.id, format!("#{key}"));
323        match &vm.public_key_jwk {
324            PublicKeyJwk::Ec { kid, crv, x, y } => {
325                assert_eq!(kid, &key);
326                assert_eq!(crv, "P-256");
327                // x is the 32-byte affine x; for a compressed point that is bytes 1..33.
328                assert_eq!(x, &URL_SAFE_NO_PAD.encode(&compressed[1..33]));
329                // y is recovered by decompression — 32 bytes, present and non-empty.
330                assert_eq!(URL_SAFE_NO_PAD.decode(y).unwrap().len(), 32);
331            }
332            other => panic!("expected EC JWK, got {other:?}"),
333        }
334    }
335
336    #[test]
337    fn multisig_emits_one_method_per_key() {
338        let k1 = ed25519_cesr(&[1u8; 32]);
339        let k2 = ed25519_cesr(&[2u8; 32]);
340        let mut state = ed25519_state("EAid000000000000000000000000000000000000000", &k1);
341        state.current_keys.push(CesrKey::new_unchecked(k2.clone()));
342        let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap();
343        assert_eq!(doc.verification_method.len(), 2);
344        assert_eq!(doc.verification_method[0].id, format!("#{k1}"));
345        assert_eq!(doc.verification_method[1].id, format!("#{k2}"));
346    }
347
348    #[test]
349    fn domain_with_port_and_path_is_preserved() {
350        // did:webs allows host%3Aport and path segments before the AID.
351        let key = ed25519_cesr(&[5u8; 32]);
352        let state = ed25519_state("EAid000000000000000000000000000000000000000", &key);
353        let doc = DidWebsDocument::from_key_state(&state, "example.com%3A3901:dids").unwrap();
354        assert_eq!(
355            doc.id,
356            "did:webs:example.com%3A3901:dids:EAid000000000000000000000000000000000000000"
357        );
358        assert_eq!(doc.verification_method[0].controller, doc.id);
359    }
360
361    #[test]
362    fn undecodable_key_is_rejected_at_the_boundary() {
363        let mut state = ed25519_state("EAid000000000000000000000000000000000000000", "Dvalid");
364        state.current_keys = vec![CesrKey::new_unchecked("Xnot-a-verkey".to_string())];
365        assert!(DidWebsDocument::from_key_state(&state, "example.com").is_err());
366    }
367
368    #[test]
369    fn document_round_trips_through_json() {
370        let key = ed25519_cesr(&[9u8; 32]);
371        let state = ed25519_state("EAid000000000000000000000000000000000000000", &key);
372        let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap();
373        let wire = serde_json::to_string(&doc).unwrap();
374        let parsed: DidWebsDocument = serde_json::from_str(&wire).unwrap();
375        assert_eq!(parsed, doc);
376    }
377}