···1212pub mod datetime;
1313/// Decentralized Identifier (DID) types and validation
1414pub mod did;
1515+/// DID Document types and helpers
1616+pub mod did_doc;
1717+/// Crypto helpers for keys (Multikey decoding, conversions)
1818+pub mod crypto;
1519/// AT Protocol handle types and validation
1620pub mod handle;
1721/// AT Protocol identifier types (handle or DID)
+298
crates/jacquard-common/src/types/crypto.rs
···11+//! Multikey decoding and optional conversions.
22+//!
33+//! This module provides a small `PublicKey` wrapper that can decode a
44+//! Multikey `publicKeyMultibase` string into raw bytes plus a codec
55+//! (`KeyCodec`). Feature‑gated helpers convert to popular Rust crypto
66+//! public‑key types (ed25519_dalek, k256, p256).
77+//! Example: decode an ed25519 multibase key
88+//! ```
99+//! use jacquard_common::types::crypto::{PublicKey, KeyCodec};
1010+//! // ed25519 key: multicodec varint 0xED + 32 raw bytes, base58btc encoded
1111+//! let mut key = [0u8; 32];
1212+//! let s = {
1313+//! fn enc(mut x: u64) -> Vec<u8> { let mut v=Vec::new(); while x>=0x80{v.push(((x as u8)&0x7F)|0x80); x >>= 7;} v.push(x as u8); v }
1414+//! let mut buf = enc(0xED); buf.extend_from_slice(&key); multibase::encode(multibase::Base::Base58Btc, buf)
1515+//! };
1616+//! let pk = PublicKey::decode(&s).unwrap();
1717+//! assert!(matches!(pk.codec, KeyCodec::Ed25519));
1818+//! assert_eq!(pk.bytes.as_ref(), &key);
1919+2020+use crate::IntoStatic;
2121+use std::borrow::Cow;
2222+2323+/// Known multicodec key codecs for Multikey public keys
2424+///
2525+2626+/// ```
2727+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2828+pub enum KeyCodec {
2929+ /// Ed25519
3030+ Ed25519,
3131+ /// Secp256k1
3232+ Secp256k1,
3333+ /// P256
3434+ P256,
3535+ /// Unknown codec
3636+ Unknown(u64),
3737+}
3838+3939+/// Public key decoded from a Multikey `publicKeyMultibase` string
4040+#[derive(Debug, Clone, PartialEq, Eq)]
4141+pub struct PublicKey<'a> {
4242+ /// Codec used to encode the public key
4343+ pub codec: KeyCodec,
4444+ /// Bytes of the public key
4545+ pub bytes: Cow<'a, [u8]>,
4646+}
4747+4848+#[cfg(feature = "crypto")]
4949+fn code_of(codec: KeyCodec) -> u64 {
5050+ match codec {
5151+ KeyCodec::Ed25519 => 0xED,
5252+ KeyCodec::Secp256k1 => 0xE7,
5353+ KeyCodec::P256 => 0x1200,
5454+ KeyCodec::Unknown(c) => c,
5555+ }
5656+}
5757+5858+/// Errors from decoding or converting Multikey values
5959+#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
6060+pub enum CryptoError {
6161+ #[error("failed to decode multibase")]
6262+ /// Multibase decode errror
6363+ MultibaseDecode,
6464+ #[error("failed to decode multicodec varint")]
6565+ /// Multicodec decode error
6666+ MulticodecDecode,
6767+ #[error("unsupported key codec: {0}")]
6868+ /// Unsupported key codec error
6969+ UnsupportedCodec(u64),
7070+ #[error("invalid key length: expected {expected}, got {got}")]
7171+ /// Invalid key length error
7272+ InvalidLength {
7373+ /// Expected length of the key
7474+ expected: usize,
7575+ /// Actual length of the key
7676+ got: usize,
7777+ },
7878+ #[error("invalid key format")]
7979+ /// Invalid key format error
8080+ InvalidFormat,
8181+ #[error("conversion error: {0}")]
8282+ /// Conversion error
8383+ Conversion(String),
8484+}
8585+8686+impl<'a> PublicKey<'a> {
8787+ /// Decode a Multikey public key from a multibase-encoded string
8888+ pub fn decode(multibase_str: &'a str) -> Result<PublicKey<'static>, CryptoError> {
8989+ let (_base, data) =
9090+ multibase::decode(multibase_str).map_err(|_| CryptoError::MultibaseDecode)?;
9191+ let (code, offset) = decode_uvarint(&data).ok_or(CryptoError::MulticodecDecode)?;
9292+ let bytes = &data[offset..];
9393+ let codec = match code {
9494+ 0xED => KeyCodec::Ed25519, // ed25519-pub
9595+ 0xE7 => KeyCodec::Secp256k1, // secp256k1-pub
9696+ 0x1200 => KeyCodec::P256, // p256-pub
9797+ other => KeyCodec::Unknown(other),
9898+ };
9999+ // Minimal validation
100100+ match codec {
101101+ KeyCodec::Ed25519 => {
102102+ if bytes.len() != 32 {
103103+ return Err(CryptoError::InvalidLength {
104104+ expected: 32,
105105+ got: bytes.len(),
106106+ });
107107+ }
108108+ }
109109+ KeyCodec::Secp256k1 | KeyCodec::P256 => {
110110+ if !(bytes.len() == 33 || bytes.len() == 65) {
111111+ return Err(CryptoError::InvalidLength {
112112+ expected: 33,
113113+ got: bytes.len(),
114114+ });
115115+ }
116116+ // 0x02/0x03 compressed, 0x04 uncompressed
117117+ let first = *bytes.first().ok_or(CryptoError::InvalidFormat)?;
118118+ if first != 0x02 && first != 0x03 && first != 0x04 {
119119+ return Err(CryptoError::InvalidFormat);
120120+ }
121121+ }
122122+ KeyCodec::Unknown(code) => return Err(CryptoError::UnsupportedCodec(code)),
123123+ }
124124+ Ok(PublicKey {
125125+ codec,
126126+ bytes: Cow::Owned(bytes.to_vec()),
127127+ })
128128+ }
129129+130130+ // decode_owned provided on PublicKey<'static>
131131+132132+ /// Convert to ed25519_dalek verifying key (feature crypto-ed25519)
133133+ #[cfg(feature = "crypto-ed25519")]
134134+ pub fn to_ed25519(&self) -> Result<ed25519_dalek::VerifyingKey, CryptoError> {
135135+ if self.codec != KeyCodec::Ed25519 {
136136+ return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
137137+ }
138138+ ed25519_dalek::VerifyingKey::from_bytes(self.bytes.as_ref().try_into().map_err(|_| {
139139+ CryptoError::InvalidLength {
140140+ expected: 32,
141141+ got: self.bytes.len(),
142142+ }
143143+ })?)
144144+ .map_err(|e| CryptoError::Conversion(e.to_string()))
145145+ }
146146+147147+ /// Convert to k256 public key (feature crypto-k256)
148148+ #[cfg(feature = "crypto-k256")]
149149+ pub fn to_k256(&self) -> Result<k256::PublicKey, CryptoError> {
150150+ if self.codec != KeyCodec::Secp256k1 {
151151+ return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
152152+ }
153153+ k256::PublicKey::from_sec1_bytes(self.bytes.as_ref())
154154+ .map_err(|e| CryptoError::Conversion(e.to_string()))
155155+ }
156156+157157+ /// Convert to p256 public key (feature crypto-p256)
158158+ #[cfg(feature = "crypto-p256")]
159159+ pub fn to_p256(&self) -> Result<p256::PublicKey, CryptoError> {
160160+ if self.codec != KeyCodec::P256 {
161161+ return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
162162+ }
163163+ p256::PublicKey::from_sec1_bytes(self.bytes.as_ref())
164164+ .map_err(|e| CryptoError::Conversion(e.to_string()))
165165+ }
166166+}
167167+168168+impl PublicKey<'static> {
169169+ /// Decode from an owned string-like value
170170+ pub fn decode_owned(s: impl AsRef<str>) -> Result<PublicKey<'static>, CryptoError> {
171171+ PublicKey::decode(s.as_ref())
172172+ }
173173+}
174174+175175+impl IntoStatic for PublicKey<'_> {
176176+ type Output = PublicKey<'static>;
177177+ fn into_static(self) -> Self::Output {
178178+ match self.bytes {
179179+ Cow::Borrowed(b) => PublicKey {
180180+ codec: self.codec,
181181+ bytes: Cow::Owned(b.to_vec()),
182182+ },
183183+ Cow::Owned(b) => PublicKey {
184184+ codec: self.codec,
185185+ bytes: Cow::Owned(b),
186186+ },
187187+ }
188188+ }
189189+}
190190+191191+fn decode_uvarint(data: &[u8]) -> Option<(u64, usize)> {
192192+ let mut x: u64 = 0;
193193+ let mut s: u32 = 0;
194194+ for (i, b) in data.iter().copied().enumerate() {
195195+ if b < 0x80 {
196196+ if i > 9 || (i == 9 && b > 1) {
197197+ return None;
198198+ }
199199+ return Some((x | ((b as u64) << s), i + 1));
200200+ }
201201+ x |= ((b & 0x7F) as u64) << s;
202202+ s += 7;
203203+ }
204204+ None
205205+}
206206+207207+#[cfg(test)]
208208+mod tests {
209209+ use super::*;
210210+ use multibase;
211211+212212+ fn encode_uvarint(mut x: u64) -> Vec<u8> {
213213+ let mut out = Vec::new();
214214+ while x >= 0x80 {
215215+ out.push(((x as u8) & 0x7F) | 0x80);
216216+ x >>= 7;
217217+ }
218218+ out.push(x as u8);
219219+ out
220220+ }
221221+222222+ fn multikey(code: u64, key: &[u8]) -> String {
223223+ let mut buf = encode_uvarint(code);
224224+ buf.extend_from_slice(key);
225225+ multibase::encode(multibase::Base::Base58Btc, buf)
226226+ }
227227+228228+ #[test]
229229+ fn decode_ed25519() {
230230+ let key = [0u8; 32];
231231+ let s = multikey(0xED, &key);
232232+ let pk = PublicKey::decode(&s).expect("decode");
233233+ assert_eq!(pk.codec, KeyCodec::Ed25519);
234234+ assert_eq!(pk.bytes.as_ref(), &key);
235235+ }
236236+237237+ #[test]
238238+ fn decode_k1_compressed() {
239239+ let mut key = [0u8; 33];
240240+ key[0] = 0x02; // compressed y-bit
241241+ let s = multikey(0xE7, &key);
242242+ let pk = PublicKey::decode(&s).expect("decode");
243243+ assert_eq!(pk.codec, KeyCodec::Secp256k1);
244244+ assert_eq!(pk.bytes.as_ref(), &key);
245245+ }
246246+247247+ #[test]
248248+ fn decode_p256_uncompressed() {
249249+ let mut key = [0u8; 65];
250250+ key[0] = 0x04; // uncompressed
251251+ let s = multikey(0x1200, &key);
252252+ let pk = PublicKey::decode(&s).expect("decode");
253253+ assert_eq!(pk.codec, KeyCodec::P256);
254254+ assert_eq!(pk.bytes.as_ref(), &key);
255255+ }
256256+257257+ #[cfg(feature = "crypto-ed25519")]
258258+ #[test]
259259+ fn ed25519_conversion_ok() {
260260+ use core::convert::TryFrom;
261261+ use ed25519_dalek::{SecretKey, SigningKey, VerifyingKey};
262262+ // Build a deterministic signing key from a fixed secret
263263+ let secret = SecretKey::try_from(&[7u8; 32][..]).expect("secret");
264264+ let sk = SigningKey::from_bytes(&secret);
265265+ let vk: VerifyingKey = sk.verifying_key();
266266+ let bytes = vk.to_bytes();
267267+ // Encode multikey: varint(0xED) + key bytes, base58btc
268268+ let mut buf = super::tests::encode_uvarint(0xED);
269269+ buf.extend_from_slice(&bytes);
270270+ let s = multibase::encode(multibase::Base::Base58Btc, buf);
271271+ let pk = PublicKey::decode(&s).expect("decode");
272272+ assert!(matches!(pk.codec, KeyCodec::Ed25519));
273273+ let vk2 = pk.to_ed25519().expect("to ed25519");
274274+ assert_eq!(vk.as_bytes(), vk2.as_bytes());
275275+ }
276276+277277+ #[cfg(feature = "crypto-k256")]
278278+ #[test]
279279+ fn k256_unsupported_on_ed25519_codec() {
280280+ // Use a valid-looking ed25519 key, attempt k256 conversion → UnsupportedCodec
281281+ let key = [1u8; 32];
282282+ let s = super::tests::multikey(0xED, &key);
283283+ let pk = PublicKey::decode(&s).expect("decode");
284284+ let err = pk.to_k256().unwrap_err();
285285+ assert!(matches!(err, CryptoError::UnsupportedCodec(_)));
286286+ }
287287+288288+ #[cfg(feature = "crypto-p256")]
289289+ #[test]
290290+ fn p256_unsupported_on_ed25519_codec() {
291291+ // Use a valid-looking ed25519 key, attempt p256 conversion → UnsupportedCodec
292292+ let key = [2u8; 32];
293293+ let s = super::tests::multikey(0xED, &key);
294294+ let pk = PublicKey::decode(&s).expect("decode");
295295+ let err = pk.to_p256().unwrap_err();
296296+ assert!(matches!(err, CryptoError::UnsupportedCodec(_)));
297297+ }
298298+}
+264
crates/jacquard-common/src/types/did_doc.rs
···11+use crate::types::crypto::{CryptoError, PublicKey};
22+use crate::types::string::{Did, Handle};
33+use crate::types::value::Data;
44+use crate::{CowStr, IntoStatic};
55+use bon::Builder;
66+use serde::{Deserialize, Serialize};
77+use smol_str::SmolStr;
88+use std::collections::BTreeMap;
99+use url::Url;
1010+1111+/// DID Document representation with borrowed data where possible.
1212+///
1313+/// Only the most commonly used fields are modeled explicitly. All other fields
1414+/// are captured in `extra_data` for forward compatibility, using the same
1515+/// pattern as lexicon structs.
1616+///
1717+/// Example
1818+/// ```ignore
1919+/// use jacquard_common::types::did_doc::DidDocument;
2020+/// use serde_json::json;
2121+/// let doc: DidDocument<'_> = serde_json::from_value(json!({
2222+/// "id": "did:plc:alice",
2323+/// "alsoKnownAs": ["at://alice.example"],
2424+/// "service": [{"id":"#pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example"}],
2525+/// "verificationMethod":[{"id":"#k","type":"Multikey","publicKeyMultibase":"z6Mki..."}]
2626+/// })).unwrap();
2727+/// assert_eq!(doc.id.as_str(), "did:plc:alice");
2828+/// assert!(doc.pds_endpoint().is_some());
2929+/// ```
3030+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
3131+#[builder(start_fn = new)]
3232+#[serde(rename_all = "camelCase")]
3333+pub struct DidDocument<'a> {
3434+ /// Document identifier (e.g., `did:plc:...` or `did:web:...`)
3535+ #[serde(borrow)]
3636+ pub id: Did<'a>,
3737+3838+ /// Alternate identifiers for the subject, such as at://<handle>
3939+ #[serde(borrow)]
4040+ pub also_known_as: Option<Vec<CowStr<'a>>>,
4141+4242+ /// Verification methods (keys) for this DID
4343+ #[serde(borrow)]
4444+ pub verification_method: Option<Vec<VerificationMethod<'a>>>,
4545+4646+ /// Services associated with this DID (e.g., AtprotoPersonalDataServer)
4747+ #[serde(borrow)]
4848+ pub service: Option<Vec<Service<'a>>>,
4949+5050+ /// Forward‑compatible capture of unmodeled fields
5151+ #[serde(flatten)]
5252+ pub extra_data: BTreeMap<SmolStr, Data<'a>>,
5353+}
5454+5555+impl crate::IntoStatic for DidDocument<'_> {
5656+ type Output = DidDocument<'static>;
5757+ fn into_static(self) -> Self::Output {
5858+ DidDocument {
5959+ id: self.id.into_static(),
6060+ also_known_as: self.also_known_as.into_static(),
6161+ verification_method: self.verification_method.into_static(),
6262+ service: self.service.into_static(),
6363+ extra_data: self.extra_data.into_static(),
6464+ }
6565+ }
6666+}
6767+6868+impl<'a> DidDocument<'a> {
6969+ /// Extract validated handles from `alsoKnownAs` entries like `at://<handle>`.
7070+ pub fn handles(&self) -> Vec<Handle<'static>> {
7171+ self.also_known_as
7272+ .as_ref()
7373+ .map(|v| {
7474+ v.iter()
7575+ .filter_map(|s| s.strip_prefix("at://"))
7676+ .filter_map(|h| Handle::new(h).ok())
7777+ .map(|h| h.into_static())
7878+ .collect()
7979+ })
8080+ .unwrap_or_default()
8181+ }
8282+8383+ /// Extract the first Multikey `publicKeyMultibase` value from verification methods.
8484+ pub fn atproto_multikey(&self) -> Option<CowStr<'static>> {
8585+ self.verification_method.as_ref().and_then(|methods| {
8686+ methods.iter().find_map(|m| {
8787+ if m.r#type.as_ref() == "Multikey" {
8888+ m.public_key_multibase
8989+ .as_ref()
9090+ .map(|k| k.clone().into_static())
9191+ } else {
9292+ None
9393+ }
9494+ })
9595+ })
9696+ }
9797+9898+ /// Extract the AtprotoPersonalDataServer service endpoint as a `Url`.
9999+ /// Accepts endpoint as string or object (string preferred).
100100+ pub fn pds_endpoint(&self) -> Option<Url> {
101101+ self.service.as_ref().and_then(|services| {
102102+ services.iter().find_map(|s| {
103103+ if s.r#type.as_ref() == "AtprotoPersonalDataServer" {
104104+ match &s.service_endpoint {
105105+ Some(Data::String(strv)) => Url::parse(strv.as_ref()).ok(),
106106+ Some(Data::Object(obj)) => {
107107+ // Some documents may include structured endpoints; try common fields
108108+ if let Some(Data::String(urlv)) = obj.0.get("url") {
109109+ Url::parse(urlv.as_ref()).ok()
110110+ } else {
111111+ None
112112+ }
113113+ }
114114+ _ => None,
115115+ }
116116+ } else {
117117+ None
118118+ }
119119+ })
120120+ })
121121+ }
122122+123123+ /// Decode the atproto Multikey (first occurrence) into a typed public key.
124124+ pub fn atproto_public_key(&self) -> Result<Option<PublicKey<'static>>, CryptoError> {
125125+ if let Some(multibase) = self.atproto_multikey() {
126126+ let pk = PublicKey::decode(&multibase)?;
127127+ Ok(Some(pk))
128128+ } else {
129129+ Ok(None)
130130+ }
131131+ }
132132+}
133133+134134+/// Verification method (key) entry in a DID Document.
135135+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
136136+#[builder(start_fn = new)]
137137+#[serde(rename_all = "camelCase")]
138138+pub struct VerificationMethod<'a> {
139139+ /// Identifier for this key material within the document
140140+ #[serde(borrow)]
141141+ pub id: CowStr<'a>,
142142+ /// Key type (e.g., `Multikey`)
143143+ #[serde(borrow, rename = "type")]
144144+ pub r#type: CowStr<'a>,
145145+ /// Optional controller DID
146146+ #[serde(borrow)]
147147+ pub controller: Option<CowStr<'a>>,
148148+ /// Multikey `publicKeyMultibase` (base58btc)
149149+ #[serde(borrow)]
150150+ pub public_key_multibase: Option<CowStr<'a>>,
151151+152152+ /// Forward‑compatible capture of unmodeled fields
153153+ #[serde(flatten)]
154154+ pub extra_data: BTreeMap<SmolStr, Data<'a>>,
155155+}
156156+157157+impl crate::IntoStatic for VerificationMethod<'_> {
158158+ type Output = VerificationMethod<'static>;
159159+ fn into_static(self) -> Self::Output {
160160+ VerificationMethod {
161161+ id: self.id.into_static(),
162162+ r#type: self.r#type.into_static(),
163163+ controller: self.controller.into_static(),
164164+ public_key_multibase: self.public_key_multibase.into_static(),
165165+ extra_data: self.extra_data.into_static(),
166166+ }
167167+ }
168168+}
169169+170170+/// Service entry in a DID Document.
171171+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
172172+#[builder(start_fn = new)]
173173+#[serde(rename_all = "camelCase")]
174174+pub struct Service<'a> {
175175+ /// Service identifier
176176+ #[serde(borrow)]
177177+ pub id: CowStr<'a>,
178178+ /// Service type (e.g., `AtprotoPersonalDataServer`)
179179+ #[serde(borrow, rename = "type")]
180180+ pub r#type: CowStr<'a>,
181181+ /// String or object; we preserve as Data
182182+ #[serde(borrow)]
183183+ pub service_endpoint: Option<Data<'a>>,
184184+185185+ /// Forward‑compatible capture of unmodeled fields
186186+ #[serde(flatten)]
187187+ pub extra_data: BTreeMap<SmolStr, Data<'a>>,
188188+}
189189+190190+impl crate::IntoStatic for Service<'_> {
191191+ type Output = Service<'static>;
192192+ fn into_static(self) -> Self::Output {
193193+ Service {
194194+ id: self.id.into_static(),
195195+ r#type: self.r#type.into_static(),
196196+ service_endpoint: self.service_endpoint.into_static(),
197197+ extra_data: self.extra_data.into_static(),
198198+ }
199199+ }
200200+}
201201+202202+#[cfg(test)]
203203+mod tests {
204204+ use super::*;
205205+ use serde_json::json;
206206+207207+ fn encode_uvarint(mut x: u64) -> Vec<u8> {
208208+ let mut out = Vec::new();
209209+ while x >= 0x80 {
210210+ out.push(((x as u8) & 0x7F) | 0x80);
211211+ x >>= 7;
212212+ }
213213+ out.push(x as u8);
214214+ out
215215+ }
216216+217217+ fn multikey(code: u64, key: &[u8]) -> String {
218218+ let mut buf = encode_uvarint(code);
219219+ buf.extend_from_slice(key);
220220+ multibase::encode(multibase::Base::Base58Btc, buf)
221221+ }
222222+223223+ #[test]
224224+ fn public_key_decode() {
225225+ let did = "did:plc:example";
226226+ let mut k = [0u8; 32];
227227+ k[0] = 7;
228228+ let mk = multikey(0xED, &k);
229229+ let doc_json = json!({
230230+ "id": did,
231231+ "verificationMethod": [
232232+ {
233233+ "id": "#key-1",
234234+ "type": "Multikey",
235235+ "publicKeyMultibase": mk,
236236+ }
237237+ ]
238238+ });
239239+ let doc_string = serde_json::to_string(&doc_json).unwrap();
240240+ let doc: DidDocument<'_> = serde_json::from_str(&doc_string).unwrap();
241241+ let pk = doc.atproto_public_key().unwrap().expect("present");
242242+ assert!(matches!(pk.codec, crate::types::crypto::KeyCodec::Ed25519));
243243+ assert_eq!(pk.bytes.as_ref(), &k);
244244+ }
245245+246246+ #[test]
247247+ fn parse_sample_doc_and_helpers() {
248248+ let raw = include_str!("test_did_doc.json");
249249+ let doc: DidDocument<'_> = serde_json::from_str(raw).expect("parse doc");
250250+ // id
251251+ assert_eq!(doc.id.as_str(), "did:plc:yfvwmnlztr4dwkb7hwz55r2g");
252252+ // pds endpoint
253253+ let pds = doc.pds_endpoint().expect("pds endpoint");
254254+ assert_eq!(pds.as_str(), "https://atproto.systems/");
255255+ // handle alias extraction
256256+ let handles = doc.handles();
257257+ assert!(handles.iter().any(|h| h.as_str() == "nonbinary.computer"));
258258+ // multikey string present
259259+ let mk = doc.atproto_multikey().expect("has multikey");
260260+ assert!(mk.as_ref().starts_with('z'));
261261+ // typed decode (may be ed25519, secp256k1, or p256 depending on multicodec)
262262+ let _ = doc.atproto_public_key().expect("decode ok");
263263+ }
264264+}
···11+//! Identity resolution utilities: DID and handle resolution, DID document fetch,
22+//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
33+pub mod resolver;
+960
crates/jacquard/src/identity/resolver.rs
···11+//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
22+//!
33+//! Fallback order (default):
44+//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → embedded XRPC
55+//! `resolveHandle` → public API fallback → Slingshot `resolveHandle` (if configured).
66+//! - DID → Doc: did:web well-known → PLC/slingshot HTTP → embedded XRPC `resolveDid`,
77+//! then Slingshot mini‑doc (partial) if configured.
88+//!
99+//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
1010+//! and optionally validate the document `id` against the requested DID.
1111+1212+use crate::CowStr;
1313+use crate::client::AuthenticatedClient;
1414+use bon::Builder;
1515+use bytes::Bytes;
1616+use jacquard_common::IntoStatic;
1717+use miette::Diagnostic;
1818+use percent_encoding::percent_decode_str;
1919+use reqwest::StatusCode;
2020+use thiserror::Error;
2121+use url::{ParseError, Url};
2222+2323+use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
2424+use crate::types::did_doc::DidDocument;
2525+use crate::types::ident::AtIdentifier;
2626+use crate::types::string::{Did, Handle};
2727+use crate::types::value::AtDataError;
2828+2929+#[cfg(feature = "dns")]
3030+use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
3131+3232+/// Errors that can occur during identity resolution.
3333+///
3434+/// Note: when validating a fetched DID document against a requested DID, a
3535+/// `DocIdMismatch` error is returned that includes the owned document so callers
3636+/// can inspect it and decide how to proceed.
3737+#[derive(Debug, Error, Diagnostic)]
3838+#[allow(missing_docs)]
3939+pub enum IdentityError {
4040+ #[error("unsupported DID method: {0}")]
4141+ UnsupportedDidMethod(String),
4242+ #[error("invalid well-known atproto-did content")]
4343+ InvalidWellKnown,
4444+ #[error("missing PDS endpoint in DID document")]
4545+ MissingPdsEndpoint,
4646+ #[error("HTTP error: {0}")]
4747+ Http(#[from] reqwest::Error),
4848+ #[error("HTTP status {0}")]
4949+ HttpStatus(StatusCode),
5050+ #[error("XRPC error: {0}")]
5151+ Xrpc(String),
5252+ #[error("URL parse error: {0}")]
5353+ Url(#[from] url::ParseError),
5454+ #[error("DNS error: {0}")]
5555+ #[cfg(feature = "dns")]
5656+ Dns(#[from] hickory_resolver::error::ResolveError),
5757+ #[error("serialize/deserialize error: {0}")]
5858+ Serde(#[from] serde_json::Error),
5959+ #[error("invalid DID document: {0}")]
6060+ InvalidDoc(String),
6161+ #[error(transparent)]
6262+ Data(#[from] AtDataError),
6363+ /// DID document id did not match requested DID; includes the fetched document
6464+ #[error("DID doc id mismatch")]
6565+ DocIdMismatch {
6666+ expected: Did<'static>,
6767+ doc: DidDocument<'static>,
6868+ },
6969+}
7070+7171+/// Source to fetch PLC (did:plc) documents from.
7272+///
7373+/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
7474+/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
7575+/// `com.atproto.identity.resolveHandle` and a "mini-doc"
7676+/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
7777+#[derive(Debug, Clone, PartialEq, Eq)]
7878+pub enum PlcSource {
7979+ /// Use the public PLC directory
8080+ PlcDirectory {
8181+ /// Base URL for the PLC directory
8282+ base: Url,
8383+ },
8484+ /// Use the slingshot mini-docs service
8585+ Slingshot {
8686+ /// Base URL for the Slingshot service
8787+ base: Url,
8888+ },
8989+}
9090+9191+impl Default for PlcSource {
9292+ fn default() -> Self {
9393+ Self::PlcDirectory {
9494+ base: Url::parse("https://plc.directory/").expect("valid url"),
9595+ }
9696+ }
9797+}
9898+9999+impl PlcSource {
100100+ /// Default Slingshot source (`https://slingshot.microcosm.blue`)
101101+ pub fn slingshot_default() -> Self {
102102+ PlcSource::Slingshot {
103103+ base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
104104+ }
105105+ }
106106+}
107107+108108+/// DID Document fetch response for borrowed/owned parsing.
109109+///
110110+/// Carries the raw response bytes and the HTTP status, plus the requested DID
111111+/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
112112+/// or `parse_validated()` to also enforce that the doc `id` matches the
113113+/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
114114+/// mismatch). Use `into_owned()` to parse into an owned document.
115115+#[derive(Clone)]
116116+pub struct DidDocResponse {
117117+ buffer: Bytes,
118118+ status: StatusCode,
119119+ /// Optional DID we intended to resolve; used for validation helpers
120120+ requested: Option<Did<'static>>,
121121+}
122122+123123+impl DidDocResponse {
124124+ /// Parse as borrowed DidDocument<'_>
125125+ pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
126126+ if self.status.is_success() {
127127+ serde_json::from_slice::<DidDocument<'b>>(&self.buffer).map_err(IdentityError::from)
128128+ } else {
129129+ Err(IdentityError::HttpStatus(self.status))
130130+ }
131131+ }
132132+133133+ /// Parse and validate that the DID in the document matches the requested DID if present.
134134+ ///
135135+ /// On mismatch, returns an error that contains the owned document for inspection.
136136+ pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
137137+ let doc = self.parse()?;
138138+ if let Some(expected) = &self.requested {
139139+ if doc.id.as_str() != expected.as_str() {
140140+ return Err(IdentityError::DocIdMismatch {
141141+ expected: expected.clone(),
142142+ doc: doc.clone().into_static(),
143143+ });
144144+ }
145145+ }
146146+ Ok(doc)
147147+ }
148148+149149+ /// Parse as owned DidDocument<'static>
150150+ pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
151151+ if self.status.is_success() {
152152+ serde_json::from_slice::<DidDocument<'_>>(&self.buffer)
153153+ .map(|d| d.into_static())
154154+ .map_err(IdentityError::from)
155155+ } else {
156156+ Err(IdentityError::HttpStatus(self.status))
157157+ }
158158+ }
159159+}
160160+161161+/// Handle → DID fallback step.
162162+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163163+pub enum HandleStep {
164164+ /// DNS TXT _atproto.<handle>
165165+ DnsTxt,
166166+ /// HTTPS GET https://<handle>/.well-known/atproto-did
167167+ HttpsWellKnown,
168168+ /// XRPC com.atproto.identity.resolveHandle against a provided PDS base
169169+ PdsResolveHandle,
170170+}
171171+172172+/// DID → Doc fallback step.
173173+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174174+pub enum DidStep {
175175+ /// For did:web: fetch from the well-known location
176176+ DidWebHttps,
177177+ /// For did:plc: fetch from PLC source
178178+ PlcHttp,
179179+ /// If a PDS base is known, ask it for the DID doc
180180+ PdsResolveDid,
181181+}
182182+183183+/// Configurable resolver options.
184184+///
185185+/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
186186+/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (auth-aware
187187+/// paths available via helpers that take an `XrpcClient`).
188188+/// - `handle_order`/`did_order`: ordered strategies for resolution.
189189+/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
190190+/// returning `DocIdMismatch` with the fetched document on mismatch.
191191+/// - `public_fallback_for_handle`: if true (default), attempt
192192+/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
193193+/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the embedded XRPC
194194+/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
195195+#[derive(Debug, Clone, Builder)]
196196+#[builder(start_fn = new)]
197197+pub struct ResolverOptions {
198198+ /// PLC data source (directory or slingshot)
199199+ pub plc_source: PlcSource,
200200+ /// Optional PDS base to use for fallbacks
201201+ pub pds_fallback: Option<Url>,
202202+ /// Order of attempts for handle → DID resolution
203203+ pub handle_order: Vec<HandleStep>,
204204+ /// Order of attempts for DID → Doc resolution
205205+ pub did_order: Vec<DidStep>,
206206+ /// Validate that fetched DID document id matches the requested DID
207207+ pub validate_doc_id: bool,
208208+ /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
209209+ pub public_fallback_for_handle: bool,
210210+}
211211+212212+impl Default for ResolverOptions {
213213+ fn default() -> Self {
214214+ // By default, prefer DNS then HTTPS for handles, then PDS fallback
215215+ // For DID documents, prefer method-native sources, then PDS fallback
216216+ Self::new()
217217+ .plc_source(PlcSource::default())
218218+ .handle_order(vec![
219219+ HandleStep::DnsTxt,
220220+ HandleStep::HttpsWellKnown,
221221+ HandleStep::PdsResolveHandle,
222222+ ])
223223+ .did_order(vec![
224224+ DidStep::DidWebHttps,
225225+ DidStep::PlcHttp,
226226+ DidStep::PdsResolveDid,
227227+ ])
228228+ .validate_doc_id(true)
229229+ .public_fallback_for_handle(true)
230230+ .build()
231231+ }
232232+}
233233+234234+/// Trait for identity resolution, for pluggable implementations.
235235+///
236236+/// The provided `DefaultResolver` supports:
237237+/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
238238+/// - HTTPS well-known for handles and `did:web`
239239+/// - PLC directory or Slingshot for `did:plc`
240240+/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
241241+/// - Auth-aware PDS fallbacks via helpers that accept an `XrpcClient`
242242+#[async_trait::async_trait]
243243+pub trait IdentityResolver {
244244+ /// Access options for validation decisions in default methods
245245+ fn options(&self) -> &ResolverOptions;
246246+247247+ /// Resolve handle
248248+ async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
249249+250250+ /// Resolve DID document
251251+ async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
252252+ async fn resolve_did_doc_owned(
253253+ &self,
254254+ did: &Did<'_>,
255255+ ) -> Result<DidDocument<'static>, IdentityError> {
256256+ self.resolve_did_doc(did).await?.into_owned()
257257+ }
258258+ async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
259259+ let resp = self.resolve_did_doc(did).await?;
260260+ let doc = resp.parse()?;
261261+ // Default-on doc id equality check
262262+ if self.options().validate_doc_id {
263263+ if doc.id.as_str() != did.as_str() {
264264+ return Err(IdentityError::DocIdMismatch {
265265+ expected: did.clone().into_static(),
266266+ doc: doc.clone().into_static(),
267267+ });
268268+ }
269269+ }
270270+ doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
271271+ }
272272+ async fn pds_for_handle(
273273+ &self,
274274+ handle: &Handle<'_>,
275275+ ) -> Result<(Did<'static>, Url), IdentityError> {
276276+ let did = self.resolve_handle(handle).await?;
277277+ let pds = self.pds_for_did(&did).await?;
278278+ Ok((did, pds))
279279+ }
280280+}
281281+282282+/// Default resolver implementation with configurable fallback order.
283283+///
284284+/// Behavior highlights:
285285+/// - Handle resolution tries DNS TXT (if enabled via `dns` feature), then HTTPS
286286+/// well-known, then Slingshot's unauthenticated `resolveHandle` when
287287+/// `PlcSource::Slingshot` is configured.
288288+/// - DID resolution tries did:web well-known for `did:web`, and the configured
289289+/// PLC base (PLC directory or Slingshot) for `did:plc`.
290290+/// - PDS-authenticated fallbacks (e.g., `resolveHandle`, `resolveDid` on a PDS)
291291+/// are available via helper methods that accept a user-provided `XrpcClient`.
292292+///
293293+/// Example
294294+/// ```ignore
295295+/// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
296296+/// use jacquard::client::{AuthenticatedClient, XrpcClient};
297297+/// use jacquard::types::string::Handle;
298298+/// use jacquard::CowStr;
299299+///
300300+/// // Build an auth-capable XRPC client (without a session it behaves like public/unauth)
301301+/// let http = reqwest::Client::new();
302302+/// let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://bsky.social"));
303303+/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default());
304304+///
305305+/// // Resolve a handle to a DID
306306+/// let did = tokio_test::block_on(async { resolver.resolve_handle(&Handle::new("bad-example.com").unwrap()).await }).unwrap();
307307+/// ```
308308+pub struct DefaultResolver<C: crate::client::XrpcClient + Send + Sync> {
309309+ http: reqwest::Client,
310310+ xrpc: C,
311311+ opts: ResolverOptions,
312312+ #[cfg(feature = "dns")]
313313+ dns: Option<TokioAsyncResolver>,
314314+}
315315+316316+impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
317317+ pub fn new(http: reqwest::Client, xrpc: C, opts: ResolverOptions) -> Self {
318318+ Self {
319319+ http,
320320+ xrpc,
321321+ opts,
322322+ #[cfg(feature = "dns")]
323323+ dns: None,
324324+ }
325325+ }
326326+327327+ #[cfg(feature = "dns")]
328328+ pub fn with_system_dns(mut self) -> Self {
329329+ self.dns = Some(TokioAsyncResolver::tokio(
330330+ ResolverConfig::default(),
331331+ Default::default(),
332332+ ));
333333+ self
334334+ }
335335+336336+ /// Set PLC source (PLC directory or Slingshot)
337337+ ///
338338+ /// Example
339339+ /// ```ignore
340340+ /// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions, PlcSource};
341341+ /// let http = reqwest::Client::new();
342342+ /// let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
343343+ /// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
344344+ /// .with_plc_source(PlcSource::slingshot_default());
345345+ /// ```
346346+ pub fn with_plc_source(mut self, source: PlcSource) -> Self {
347347+ self.opts.plc_source = source;
348348+ self
349349+ }
350350+351351+ /// Enable/disable public unauthenticated fallback for resolveHandle
352352+ ///
353353+ /// Example
354354+ /// ```ignore
355355+ /// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
356356+ /// # let http = reqwest::Client::new();
357357+ /// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
358358+ /// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
359359+ /// .with_public_fallback_for_handle(true);
360360+ /// ```
361361+ pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
362362+ self.opts.public_fallback_for_handle = enable;
363363+ self
364364+ }
365365+366366+ /// Enable/disable doc id validation
367367+ ///
368368+ /// Example
369369+ /// ```ignore
370370+ /// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
371371+ /// # let http = reqwest::Client::new();
372372+ /// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
373373+ /// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
374374+ /// .with_validate_doc_id(true);
375375+ /// ```
376376+ pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
377377+ self.opts.validate_doc_id = enable;
378378+ self
379379+ }
380380+381381+ /// Construct the well-known HTTPS URL for a `did:web` DID.
382382+ ///
383383+ /// - `did:web:example.com` → `https://example.com/.well-known/did.json`
384384+ /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json`
385385+ fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
386386+ // did:web:example.com[:path:segments]
387387+ let s = did.as_str();
388388+ let rest = s
389389+ .strip_prefix("did:web:")
390390+ .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
391391+ let mut parts = rest.split(':');
392392+ let host = parts
393393+ .next()
394394+ .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
395395+ let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?;
396396+ let path: Vec<&str> = parts.collect();
397397+ if path.is_empty() {
398398+ url.set_path(".well-known/did.json");
399399+ } else {
400400+ // Append path segments and did.json
401401+ let mut segments = url
402402+ .path_segments_mut()
403403+ .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?;
404404+ for seg in path {
405405+ // Minimally percent-decode each segment per spec guidance
406406+ let decoded = percent_decode_str(seg).decode_utf8_lossy();
407407+ segments.push(&decoded);
408408+ }
409409+ segments.push("did.json");
410410+ // drop segments
411411+ }
412412+ Ok(url)
413413+ }
414414+415415+ #[cfg(test)]
416416+ fn test_did_web_url_raw(&self, s: &str) -> String {
417417+ let did = Did::new(s).unwrap();
418418+ self.did_web_url(&did).unwrap().to_string()
419419+ }
420420+421421+ async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
422422+ let resp = self.http.get(url).send().await?;
423423+ let status = resp.status();
424424+ let buf = resp.bytes().await?;
425425+ Ok((buf, status))
426426+ }
427427+428428+ async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
429429+ let resp = self.http.get(url).send().await?;
430430+ if resp.status() == StatusCode::OK {
431431+ Ok(resp.text().await?)
432432+ } else {
433433+ Err(IdentityError::Http(resp.error_for_status().unwrap_err()))
434434+ }
435435+ }
436436+437437+ #[cfg(feature = "dns")]
438438+ async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> {
439439+ let Some(dns) = &self.dns else {
440440+ return Ok(vec![]);
441441+ };
442442+ let fqdn = format!("_atproto.{name}.");
443443+ let response = dns.txt_lookup(fqdn).await?;
444444+ let mut out = Vec::new();
445445+ for txt in response.iter() {
446446+ for data in txt.txt_data().iter() {
447447+ out.push(String::from_utf8_lossy(data).to_string());
448448+ }
449449+ }
450450+ Ok(out)
451451+ }
452452+453453+ fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> {
454454+ let line = body
455455+ .lines()
456456+ .find(|l| !l.trim().is_empty())
457457+ .ok_or(IdentityError::InvalidWellKnown)?;
458458+ let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?;
459459+ Ok(did.into_static())
460460+ }
461461+}
462462+463463+impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
464464+ /// Resolve handle to DID via a PDS XRPC client (auth-aware path)
465465+ pub async fn resolve_handle_via_pds(
466466+ &self,
467467+ handle: &Handle<'_>,
468468+ ) -> Result<Did<'static>, IdentityError> {
469469+ let req = ResolveHandle::new().handle((*handle).clone()).build();
470470+ let resp = self
471471+ .xrpc
472472+ .send(req)
473473+ .await
474474+ .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
475475+ let out = resp
476476+ .into_output()
477477+ .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
478478+ Did::new_owned(out.did.as_str())
479479+ .map(|d| d.into_static())
480480+ .map_err(|_| IdentityError::InvalidWellKnown)
481481+ }
482482+483483+ /// Fetch DID document via PDS resolveDid (returns owned DidDocument)
484484+ pub async fn fetch_did_doc_via_pds_owned(
485485+ &self,
486486+ did: &Did<'_>,
487487+ ) -> Result<DidDocument<'static>, IdentityError> {
488488+ let req = resolve_did::ResolveDid::new().did(did.clone()).build();
489489+ let resp = self
490490+ .xrpc
491491+ .send(req)
492492+ .await
493493+ .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
494494+ let out = resp
495495+ .into_output()
496496+ .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
497497+ let doc_json = serde_json::to_value(&out.did_doc)?;
498498+ let s = serde_json::to_string(&doc_json)?;
499499+ let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
500500+ Ok(doc_borrowed.into_static())
501501+ }
502502+503503+ /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
504504+ /// Returns the raw response wrapper for borrowed parsing and validation.
505505+ pub async fn fetch_mini_doc_via_slingshot(
506506+ &self,
507507+ did: &Did<'_>,
508508+ ) -> Result<DidDocResponse, IdentityError> {
509509+ let base = match &self.opts.plc_source {
510510+ PlcSource::Slingshot { base } => base.clone(),
511511+ _ => {
512512+ return Err(IdentityError::UnsupportedDidMethod(
513513+ "mini-doc requires Slingshot source".into(),
514514+ ));
515515+ }
516516+ };
517517+ let mut url = base;
518518+ url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
519519+ if let Ok(qs) =
520520+ serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build())
521521+ {
522522+ url.set_query(Some(&qs));
523523+ }
524524+ let (buf, status) = self.get_json_bytes(url).await?;
525525+ Ok(DidDocResponse {
526526+ buffer: buf,
527527+ status,
528528+ requested: Some(did.clone().into_static()),
529529+ })
530530+ }
531531+}
532532+533533+#[async_trait::async_trait]
534534+impl<C: crate::client::XrpcClient + Send + Sync> IdentityResolver for DefaultResolver<C> {
535535+ fn options(&self) -> &ResolverOptions {
536536+ &self.opts
537537+ }
538538+ async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
539539+ let host = handle.as_str();
540540+ for step in &self.opts.handle_order {
541541+ match step {
542542+ HandleStep::DnsTxt => {
543543+ #[cfg(feature = "dns")]
544544+ {
545545+ if let Ok(txts) = self.dns_txt(host).await {
546546+ for txt in txts {
547547+ if let Some(did_str) = txt.strip_prefix("did=") {
548548+ if let Ok(did) = Did::new(did_str) {
549549+ return Ok(did.into_static());
550550+ }
551551+ }
552552+ }
553553+ }
554554+ }
555555+ }
556556+ HandleStep::HttpsWellKnown => {
557557+ let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
558558+ if let Ok(text) = self.get_text(url).await {
559559+ if let Ok(did) = Self::parse_atproto_did_body(&text) {
560560+ return Ok(did);
561561+ }
562562+ }
563563+ }
564564+ HandleStep::PdsResolveHandle => {
565565+ // Prefer embedded XRPC client
566566+ if let Ok(did) = self.resolve_handle_via_pds(handle).await {
567567+ return Ok(did);
568568+ }
569569+ // Public unauth fallback
570570+ if self.opts.public_fallback_for_handle {
571571+ if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
572572+ url.set_path("/xrpc/com.atproto.identity.resolveHandle");
573573+ if let Ok(qs) = serde_html_form::to_string(
574574+ &ResolveHandle::new().handle((*handle).clone()).build(),
575575+ ) {
576576+ url.set_query(Some(&qs));
577577+ } else {
578578+ continue;
579579+ }
580580+ if let Ok((buf, status)) = self.get_json_bytes(url).await {
581581+ if status.is_success() {
582582+ if let Ok(val) =
583583+ serde_json::from_slice::<serde_json::Value>(&buf)
584584+ {
585585+ if let Some(did_str) =
586586+ val.get("did").and_then(|v| v.as_str())
587587+ {
588588+ if let Ok(did) = Did::new_owned(did_str) {
589589+ return Ok(did.into_static());
590590+ }
591591+ }
592592+ }
593593+ }
594594+ }
595595+ }
596596+ }
597597+ // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
598598+ if let PlcSource::Slingshot { base } = &self.opts.plc_source {
599599+ let mut url = base.clone();
600600+ url.set_path("/xrpc/com.atproto.identity.resolveHandle");
601601+ if let Ok(qs) = serde_html_form::to_string(
602602+ &ResolveHandle::new().handle((*handle).clone()).build(),
603603+ ) {
604604+ url.set_query(Some(&qs));
605605+ } else {
606606+ continue;
607607+ }
608608+ if let Ok((buf, status)) = self.get_json_bytes(url).await {
609609+ if status.is_success() {
610610+ if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
611611+ if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
612612+ if let Ok(did) = Did::new_owned(did_str) {
613613+ return Ok(did.into_static());
614614+ }
615615+ }
616616+ }
617617+ }
618618+ }
619619+ }
620620+ }
621621+ }
622622+ }
623623+ Err(IdentityError::InvalidWellKnown)
624624+ }
625625+626626+ async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
627627+ let s = did.as_str();
628628+ for step in &self.opts.did_order {
629629+ match step {
630630+ DidStep::DidWebHttps if s.starts_with("did:web:") => {
631631+ let url = self.did_web_url(did)?;
632632+ if let Ok((buf, status)) = self.get_json_bytes(url).await {
633633+ return Ok(DidDocResponse {
634634+ buffer: buf,
635635+ status,
636636+ requested: Some(did.clone().into_static()),
637637+ });
638638+ }
639639+ }
640640+ DidStep::PlcHttp if s.starts_with("did:plc:") => {
641641+ let url = match &self.opts.plc_source {
642642+ PlcSource::PlcDirectory { base } => base.join(did.as_str())?,
643643+ PlcSource::Slingshot { base } => base.join(did.as_str())?,
644644+ };
645645+ if let Ok((buf, status)) = self.get_json_bytes(url).await {
646646+ return Ok(DidDocResponse {
647647+ buffer: buf,
648648+ status,
649649+ requested: Some(did.clone().into_static()),
650650+ });
651651+ }
652652+ }
653653+ DidStep::PdsResolveDid => {
654654+ // Try embedded XRPC client for full DID doc
655655+ if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
656656+ let buf = serde_json::to_vec(&doc).unwrap_or_default();
657657+ return Ok(DidDocResponse {
658658+ buffer: Bytes::from(buf),
659659+ status: StatusCode::OK,
660660+ requested: Some(did.clone().into_static()),
661661+ });
662662+ }
663663+ // Fallback: if Slingshot configured, return mini-doc response (partial doc)
664664+ if let PlcSource::Slingshot { base } = &self.opts.plc_source {
665665+ let url = self.slingshot_mini_doc_url(base, did.as_str())?;
666666+ let (buf, status) = self.get_json_bytes(url).await?;
667667+ return Ok(DidDocResponse {
668668+ buffer: buf,
669669+ status,
670670+ requested: Some(did.clone().into_static()),
671671+ });
672672+ }
673673+ }
674674+ _ => {}
675675+ }
676676+ }
677677+ Err(IdentityError::UnsupportedDidMethod(s.to_string()))
678678+ }
679679+}
680680+681681+/// Warnings produced during identity checks that are not fatal
682682+#[derive(Debug, Clone, PartialEq, Eq)]
683683+pub enum IdentityWarning {
684684+ /// The DID doc did not contain the expected handle alias under alsoKnownAs
685685+ HandleAliasMismatch { expected: Handle<'static> },
686686+}
687687+688688+impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
689689+ /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
690690+ /// This applies the default equality check on the document id (error with doc if mismatch).
691691+ pub async fn resolve_handle_and_doc(
692692+ &self,
693693+ handle: &Handle<'_>,
694694+ ) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> {
695695+ let did = self.resolve_handle(handle).await?;
696696+ let resp = self.resolve_did_doc(&did).await?;
697697+ let resp_for_parse = resp.clone();
698698+ let doc_borrowed = resp_for_parse.parse()?;
699699+ if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
700700+ return Err(IdentityError::DocIdMismatch {
701701+ expected: did.clone().into_static(),
702702+ doc: doc_borrowed.clone().into_static(),
703703+ });
704704+ }
705705+ let mut warnings = Vec::new();
706706+ // Check handle alias presence (soft warning)
707707+ let expected_alias = format!("at://{}", handle.as_str());
708708+ let has_alias = doc_borrowed
709709+ .also_known_as
710710+ .as_ref()
711711+ .map(|v| v.iter().any(|s| s.as_ref() == expected_alias))
712712+ .unwrap_or(false);
713713+ if !has_alias {
714714+ warnings.push(IdentityWarning::HandleAliasMismatch {
715715+ expected: handle.clone().into_static(),
716716+ });
717717+ }
718718+ Ok((did, resp, warnings))
719719+ }
720720+721721+ /// Build Slingshot mini-doc URL for an identifier (handle or DID)
722722+ fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> {
723723+ let mut url = base.clone();
724724+ url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
725725+ url.set_query(Some(&format!(
726726+ "identifier={}",
727727+ urlencoding::Encoded::new(identifier)
728728+ )));
729729+ Ok(url)
730730+ }
731731+732732+ /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
733733+ pub async fn fetch_mini_doc_via_slingshot_identifier(
734734+ &self,
735735+ identifier: &AtIdentifier<'_>,
736736+ ) -> Result<MiniDocResponse, IdentityError> {
737737+ let base = match &self.opts.plc_source {
738738+ PlcSource::Slingshot { base } => base.clone(),
739739+ _ => {
740740+ return Err(IdentityError::UnsupportedDidMethod(
741741+ "mini-doc requires Slingshot source".into(),
742742+ ));
743743+ }
744744+ };
745745+ let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
746746+ let (buf, status) = self.get_json_bytes(url).await?;
747747+ Ok(MiniDocResponse {
748748+ buffer: buf,
749749+ status,
750750+ })
751751+ }
752752+}
753753+754754+/// Slingshot mini-doc JSON response wrapper
755755+#[derive(Clone)]
756756+pub struct MiniDocResponse {
757757+ buffer: Bytes,
758758+ status: StatusCode,
759759+}
760760+761761+impl MiniDocResponse {
762762+ /// Parse borrowed MiniDoc
763763+ pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> {
764764+ if self.status.is_success() {
765765+ serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
766766+ } else {
767767+ Err(IdentityError::HttpStatus(self.status))
768768+ }
769769+ }
770770+}
771771+772772+/// Slingshot mini-doc data (subset of DID doc info)
773773+#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
774774+#[serde(rename_all = "camelCase")]
775775+pub struct MiniDoc<'a> {
776776+ #[serde(borrow)]
777777+ pub did: Did<'a>,
778778+ #[serde(borrow)]
779779+ pub handle: Handle<'a>,
780780+ #[serde(borrow)]
781781+ pub pds: crate::CowStr<'a>,
782782+ #[serde(borrow, rename = "signingKey", alias = "signing_key")]
783783+ pub signing_key: crate::CowStr<'a>,
784784+}
785785+786786+#[cfg(test)]
787787+mod tests {
788788+ use super::*;
789789+790790+ #[test]
791791+ fn did_web_urls() {
792792+ let r = DefaultResolver::new(
793793+ reqwest::Client::new(),
794794+ TestXrpc::new(),
795795+ ResolverOptions::default(),
796796+ );
797797+ assert_eq!(
798798+ r.test_did_web_url_raw("did:web:example.com"),
799799+ "https://example.com/.well-known/did.json"
800800+ );
801801+ assert_eq!(
802802+ r.test_did_web_url_raw("did:web:example.com:user:alice"),
803803+ "https://example.com/user/alice/did.json"
804804+ );
805805+ }
806806+807807+ #[test]
808808+ fn parse_validated_ok() {
809809+ let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
810810+ let requested = Did::new_owned("did:plc:alice").unwrap();
811811+ let resp = DidDocResponse {
812812+ buffer: buf,
813813+ status: StatusCode::OK,
814814+ requested: Some(requested),
815815+ };
816816+ let _doc = resp.parse_validated().expect("valid");
817817+ }
818818+819819+ #[test]
820820+ fn parse_validated_mismatch() {
821821+ let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
822822+ let requested = Did::new_owned("did:plc:alice").unwrap();
823823+ let resp = DidDocResponse {
824824+ buffer: buf,
825825+ status: StatusCode::OK,
826826+ requested: Some(requested),
827827+ };
828828+ match resp.parse_validated() {
829829+ Err(IdentityError::DocIdMismatch { expected, doc }) => {
830830+ assert_eq!(expected.as_str(), "did:plc:alice");
831831+ assert_eq!(doc.id.as_str(), "did:plc:bob");
832832+ }
833833+ other => panic!("unexpected result: {:?}", other),
834834+ }
835835+ }
836836+837837+ #[test]
838838+ fn slingshot_mini_doc_url_build() {
839839+ let r = DefaultResolver::new(
840840+ reqwest::Client::new(),
841841+ TestXrpc::new(),
842842+ ResolverOptions::default(),
843843+ );
844844+ let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
845845+ let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
846846+ assert_eq!(
847847+ url.as_str(),
848848+ "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
849849+ );
850850+ }
851851+852852+ #[test]
853853+ fn slingshot_mini_doc_parse_success() {
854854+ let buf = Bytes::from_static(
855855+ br#"{
856856+ "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
857857+ "handle": "bad-example.com",
858858+ "pds": "https://porcini.us-east.host.bsky.network",
859859+ "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
860860+}"#,
861861+ );
862862+ let resp = MiniDocResponse {
863863+ buffer: buf,
864864+ status: StatusCode::OK,
865865+ };
866866+ let doc = resp.parse().expect("parse mini-doc");
867867+ assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
868868+ assert_eq!(doc.handle.as_str(), "bad-example.com");
869869+ assert_eq!(
870870+ doc.pds.as_ref(),
871871+ "https://porcini.us-east.host.bsky.network"
872872+ );
873873+ assert!(doc.signing_key.as_ref().starts_with('z'));
874874+ }
875875+876876+ #[test]
877877+ fn slingshot_mini_doc_parse_error_status() {
878878+ let buf = Bytes::from_static(
879879+ br#"{
880880+ "error": "RecordNotFound",
881881+ "message": "This record was deleted"
882882+}"#,
883883+ );
884884+ let resp = MiniDocResponse {
885885+ buffer: buf,
886886+ status: StatusCode::BAD_REQUEST,
887887+ };
888888+ match resp.parse() {
889889+ Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST),
890890+ other => panic!("unexpected: {:?}", other),
891891+ }
892892+ }
893893+ use crate::client::{HttpClient, XrpcClient};
894894+ use http::Request;
895895+ use jacquard_common::CowStr;
896896+897897+ struct TestXrpc {
898898+ client: reqwest::Client,
899899+ }
900900+ impl TestXrpc {
901901+ fn new() -> Self {
902902+ Self {
903903+ client: reqwest::Client::new(),
904904+ }
905905+ }
906906+ }
907907+ impl HttpClient for TestXrpc {
908908+ type Error = reqwest::Error;
909909+ async fn send_http(
910910+ &self,
911911+ request: Request<Vec<u8>>,
912912+ ) -> Result<http::Response<Vec<u8>>, Self::Error> {
913913+ self.client.send_http(request).await
914914+ }
915915+ }
916916+ impl XrpcClient for TestXrpc {
917917+ fn base_uri(&self) -> CowStr<'_> {
918918+ CowStr::from("https://public.api.bsky.app")
919919+ }
920920+ }
921921+}
922922+923923+/// Resolver specialized for unauthenticated/public flows using reqwest + AuthenticatedClient
924924+pub type PublicResolver = DefaultResolver<AuthenticatedClient<reqwest::Client>>;
925925+926926+impl Default for PublicResolver {
927927+ /// Build a resolver with:
928928+ /// - reqwest HTTP client
929929+ /// - XRPC base https://public.api.bsky.app (unauthenticated)
930930+ /// - default options (DNS enabled if compiled, public fallback for handles enabled)
931931+ ///
932932+ /// Example
933933+ /// ```ignore
934934+ /// use jacquard::identity::resolver::PublicResolver;
935935+ /// let resolver = PublicResolver::default();
936936+ /// ```
937937+ fn default() -> Self {
938938+ let http = reqwest::Client::new();
939939+ let xrpc =
940940+ AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
941941+ let opts = ResolverOptions::default();
942942+ let resolver = DefaultResolver::new(http, xrpc, opts);
943943+ #[cfg(feature = "dns")]
944944+ let resolver = resolver.with_system_dns();
945945+ resolver
946946+ }
947947+}
948948+949949+/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
950950+/// mini-doc fallbacks, unauthenticated by default.
951951+pub fn slingshot_resolver_default() -> PublicResolver {
952952+ let http = reqwest::Client::new();
953953+ let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
954954+ let mut opts = ResolverOptions::default();
955955+ opts.plc_source = PlcSource::slingshot_default();
956956+ let resolver = DefaultResolver::new(http, xrpc, opts);
957957+ #[cfg(feature = "dns")]
958958+ let resolver = resolver.with_system_dns();
959959+ resolver
960960+}
+4-1
crates/jacquard/src/lib.rs
···1919//!
2020//! Dead simple api client. Logs in, prints the latest 5 posts from your timeline.
2121//!
2222-//! ```rust
2222+//! ```no_run
2323//! # use clap::Parser;
2424//! # use jacquard::CowStr;
2525//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
···100100#[cfg(feature = "derive")]
101101/// if enabled, reexport the attribute macros
102102pub use jacquard_derive::*;
103103+104104+/// Identity resolution helpers (DIDs, handles, PDS endpoints)
105105+pub mod identity;
+14
docs/identity.md
···11+# Identity Resolution
22+33+This module provides helpers for resolving AT Protocol identifiers (handles and DIDs) and fetching DID documents.
44+55+Highlights:
66+77+- DNS TXT (`_atproto.<handle>`) first when compiled with the `dns` feature, then HTTPS well-known, then Slingshot `resolveHandle` when configured as PLC source.
88+- DID resolution via did:web well-known or PLC base (PLC Directory or Slingshot), returning a `DidDocResponse` that supports borrowed parsing and validation.
99+- Validation: convenience helpers validate that the fetched DID document `id` matches the requested DID (default on). On mismatch, a `DocIdMismatch` error includes the fetched document for callers to inspect.
1010+- Slingshot: supports unauthenticated `resolveHandle` and a minimal-document endpoint (`com.bad-example.identity.resolveMiniDoc`).
1111+- Auth-aware fallbacks: PDS `resolveHandle` / `resolveDid` available via helpers that accept an `XrpcClient`.
1212+1313+See `jacquard::identity::resolver` rustdoc for examples.
1414+