Skip to main content

dig_identity/
resolve.rs

1//! WU3 — on-chain DID resolution: from a `did:chia:` string to its **chain-authenticated** profile.
2//!
3//! WU1 ([`crate::pairing`], [`crate::identity_profile`]) reasons over caller-supplied records and is
4//! sound only RELATIVE TO an [`IdentitySingleton`] `lineage` the caller resolved on-chain. WU3 closes
5//! that boundary: it derives everything trust-critical from the DID string itself, using a caller-
6//! supplied [`ChainSource`] purely as an honest READER of chain state — never as a source of
7//! authority claims.
8//!
9//! ## The resolution, and why each step is trust-critical
10//!
11//! Given only a DID string:
12//!
13//! 1. **Parse** the DID → its permanent `launcher_id` (canonical bech32m — [`Did::parse`]).
14//! 2. **Resolve the authentic singleton lineage.** Walk the DID singleton's lineage from `launcher_id`
15//!    to its current unspent tip ([`ChainSource::resolve_singleton_lineage`]). Every coin id on that
16//!    walk is trusted as the DID's [`SingletonLineage`]; its tip is [`IdentitySingleton::coin_id`]. It
17//!    is derived from the DID (via `launcher_id`), NEVER accepted from a producer — this is what
18//!    defeats the authority-laundering spoof (an attacker handing you their own launcher coin + a store
19//!    that merely names the victim DID in its description).
20//! 3. **Discover candidate stores** whose on-chain description names the DID
21//!    ([`ChainSource::find_stores_for_did`]) and keep ONLY those whose launcher parent is a MEMBER of
22//!    the authentic singleton lineage from step 2 (the [`crate::pairing`] predicate — description AND
23//!    launch-from-DID lineage). Membership (not tip-equality) is required because launching a store
24//!    from a DID recreates the DID coin in the same spend, so the launcher parent is a PAST lineage
25//!    coin, never the current tip. Zero → [`ResolveError::NoProfile`]; more than one →
26//!    [`ResolveError::AmbiguousProfile`].
27//! 4. **Bind the root.** Fetch the chosen store's profile content ([`ChainSource::fetch_profile`]) and
28//!    require it to hash to that store's CURRENT on-chain `root_hash` — a stale/rolled-back/tampered
29//!    body is [`ResolveError::StaleOrTamperedRoot`]. Only then are the profile's key slots trusted.
30//!
31//! The result is an [`IdentityProfile`] whose `singleton.lineage` and `root` are both chain-derived,
32//! so [`IdentityProfile::did`] / [`IdentityProfile::store_belongs_to_did`] / the resolved keys are
33//! authority a consumer (dig-node's `DidSigningKeyResolver`, dig-chat, the extension, hub) can trust.
34//!
35//! ## Trust model of [`ChainSource`]
36//!
37//! The `ChainSource` MUST be the caller's OWN honest view of the chain (a full node / coinset client),
38//! not an attacker-controlled channel. WU3 assumes the source reports real chain state; it does not
39//! and cannot defend against a source that fabricates the chain itself. Its job is to ensure that,
40//! given honest chain data, no third-party-supplied record can launder itself into DID authority.
41
42use chia_protocol::Bytes32;
43
44use crate::did::Did;
45use crate::identity_profile::IdentityProfile;
46use crate::keys::DidKeys;
47use crate::pairing::{is_authoritative_profile, IdentitySingleton, SingletonLineage, StoreRecord};
48use crate::profile::Profile;
49
50/// A candidate profile store as READ FROM CHAIN: its pairing record plus its current committed root.
51///
52/// The [`ChainSource`] returns one of these per store whose description names the DID being resolved.
53/// `root_hash` MUST be the store singleton's CURRENT on-chain root (the source is responsible for
54/// walking the store lineage to its tip); WU3 binds the fetched profile content to it.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ChainStoreState {
57    /// The store's pairing record (description + launcher coin) built from canonical chain types.
58    pub store: StoreRecord,
59    /// The store singleton's current on-chain committed profile root.
60    pub root_hash: Bytes32,
61}
62
63/// A caller-supplied, honest READER of Chia chain state — the seam that keeps dig-identity chain- and
64/// network-independent (so it still builds for wasm / no-network targets).
65///
66/// A consumer (dig-node, dig-chat, the extension, hub) implements this over its own chain backend
67/// (coinset.org, a local full node, `chia-query`). WU3 supplies ALL the trust logic on top; the
68/// source only fetches. See the module trust model: the source MUST be honest chain data — it is
69/// never treated as a source of authority claims.
70pub trait ChainSource {
71    /// The source's own fetch/transport error, surfaced verbatim through [`ResolveError::Chain`].
72    type Error: core::fmt::Display;
73
74    /// Walks the singleton lineage from `launcher_id` to its current unspent tip, returning EVERY coin
75    /// id on that walk as a [`SingletonLineage`].
76    ///
77    /// Returns `None` when the launcher never existed or the singleton has been fully spent (melted).
78    /// The returned lineage is the value WU3 trusts as the identity singleton's authentic lineage — so
79    /// this MUST be a genuine forward walk from the DID launcher to its tip (each coin the singleton
80    /// recreation of the previous), NEVER an echo of a caller-supplied coin. WU3 accepts a store whose
81    /// launcher parent is ANY member of this lineage (a store launched from the DID parents to the DID
82    /// coin at spend time, which the same spend advances past — so the parent is a past lineage coin,
83    /// not the tip). The caller implements the walk against its own chain backend (coinset / full node).
84    fn resolve_singleton_lineage(
85        &self,
86        launcher_id: Bytes32,
87    ) -> Result<Option<SingletonLineage>, Self::Error>;
88
89    /// Returns every store whose CURRENT on-chain description names `did` (the discovery scan).
90    ///
91    /// Over-returning is safe: WU3 re-checks the full pairing predicate (description AND launcher
92    /// parent is a member of the chain-resolved singleton lineage), so non-authoritative candidates are
93    /// discarded. The source need not itself enforce authority.
94    fn find_stores_for_did(&self, did: &Did) -> Result<Vec<ChainStoreState>, Self::Error>;
95
96    /// Fetches the profile SMT content a store committed under `root_hash`.
97    ///
98    /// The returned [`Profile`] is UNTRUSTED until WU3 confirms it hashes to `root_hash`; the source
99    /// only needs to return the store's current profile body (e.g. from the DataLayer store content).
100    fn fetch_profile(
101        &self,
102        store: &StoreRecord,
103        root_hash: Bytes32,
104    ) -> Result<Profile, Self::Error>;
105}
106
107/// Why an on-chain DID resolution failed. Every variant fails CLOSED — a resolver never yields
108/// authority it could not fully authenticate against the chain.
109#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110pub enum ResolveError {
111    /// The input string is not a valid `did:chia:` DID.
112    #[error("not a valid did:chia: DID")]
113    InvalidDid,
114
115    /// The DID's singleton launcher has no current unspent coin (never launched, or fully melted), so
116    /// there is no authentic singleton coin to anchor authority to.
117    #[error("DID singleton has no current on-chain coin (unlaunched or melted)")]
118    NoIdentitySingleton,
119
120    /// No store both names the DID AND was launched from its authentic singleton coin — the DID
121    /// publishes no authoritative profile.
122    #[error("no authoritative profile store found for the DID")]
123    NoProfile,
124
125    /// More than one store satisfies the pairing predicate against the DID's singleton coin; authority
126    /// is ambiguous and MUST NOT be guessed.
127    #[error("multiple authoritative profile stores found for the DID (ambiguous)")]
128    AmbiguousProfile,
129
130    /// The fetched profile content does not hash to the store's current on-chain root — a stale,
131    /// rolled-back, or tampered body. The keys it carries are not trusted.
132    #[error("profile content does not match the store's current on-chain root")]
133    StaleOrTamperedRoot,
134
135    /// The DID's authoritative profile publishes no BLS12-381 G1 identity key (slot `0x0010`).
136    #[error("DID profile publishes no BLS G1 identity key")]
137    NoIdentityKey,
138
139    /// The profile content could not be decoded / its root could not be computed.
140    #[error("profile format error: {0}")]
141    Format(#[from] crate::error::Error),
142
143    /// The underlying [`ChainSource`] failed to read chain state.
144    #[error("chain source error: {0}")]
145    Chain(String),
146}
147
148/// Resolves a `did:chia:` DID to its **chain-authenticated** [`IdentityProfile`].
149///
150/// This is the trust anchor of the crate: unlike [`IdentityProfile::resolve`] (which trusts a
151/// caller-supplied `lineage`), this derives the singleton lineage and the profile root from the DID via
152/// `source`, so the returned profile's DID authority and keys are chain-backed. See the module docs
153/// for the step-by-step guarantee. Fails closed on every ambiguity or mismatch (see [`ResolveError`]).
154pub fn resolve_identity_profile<S: ChainSource>(
155    did_uri: &str,
156    source: &S,
157) -> Result<IdentityProfile, ResolveError> {
158    let did = Did::parse(did_uri).ok_or(ResolveError::InvalidDid)?;
159
160    // The authentic singleton lineage, derived from the DID (never producer-supplied).
161    let lineage = source
162        .resolve_singleton_lineage(did.launcher_id())
163        .map_err(chain_error)?
164        .ok_or(ResolveError::NoIdentitySingleton)?;
165    let singleton = IdentitySingleton {
166        did: did.clone(),
167        lineage,
168    };
169
170    // Keep only candidates that satisfy the FULL pairing predicate: description names the DID AND the
171    // launcher parent is a member of the authentic lineage (a genuine launch-from-DID coin).
172    let mut authentic = source
173        .find_stores_for_did(&did)
174        .map_err(chain_error)?
175        .into_iter()
176        .filter(|candidate| is_authoritative_profile(&candidate.store, &singleton));
177
178    let chosen = authentic.next().ok_or(ResolveError::NoProfile)?;
179    if authentic.next().is_some() {
180        return Err(ResolveError::AmbiguousProfile);
181    }
182
183    // Bind the fetched profile body to the store's current on-chain root.
184    let content = source
185        .fetch_profile(&chosen.store, chosen.root_hash)
186        .map_err(chain_error)?;
187    if Bytes32::new(content.build_root()?) != chosen.root_hash {
188        return Err(ResolveError::StaleOrTamperedRoot);
189    }
190
191    // Re-runs the pairing predicate on construction (already satisfied); binds the trusted root.
192    Ok(IdentityProfile::resolve(singleton, chosen.store, content)?)
193}
194
195/// Resolves a DID to the cryptographic keys its authoritative profile publishes (slots `0x0010`–
196/// `0x0013`), chain-authenticated end to end.
197///
198/// The dig-chat / dig-node resolution seam: any absent key slot is `None` (a profile may publish some
199/// keys and not others), but the RESOLUTION itself fails closed — an unresolvable or spoofed DID
200/// yields a [`ResolveError`], never an empty [`DidKeys`].
201pub fn resolve_did_keys<S: ChainSource>(
202    did_uri: &str,
203    source: &S,
204) -> Result<DidKeys, ResolveError> {
205    Ok(resolve_identity_profile(did_uri, source)?.keys())
206}
207
208/// Resolves a DID to its BLS12-381 G1 identity public key (slot `0x0010`), chain-authenticated.
209///
210/// The exact seam dig-message (seal + sender signature) and dig-node's engine `DidSigningKeyResolver`
211/// (#1007) consume: it returns the 48-byte compressed G1 key or fails closed with
212/// [`ResolveError::NoIdentityKey`] when the authoritative profile publishes none — so a caller can
213/// only obtain a key that a chain-authenticated DID actually published, never one attached by an
214/// unauthenticated party. A caller intending to DH against the key MUST still run the §6a.3 subgroup
215/// check ([`crate::bls::g1_dh`] does this internally).
216pub fn resolve_bls_public_key<S: ChainSource>(
217    did_uri: &str,
218    source: &S,
219) -> Result<[u8; 48], ResolveError> {
220    resolve_did_keys(did_uri, source)?
221        .bls_g1_public_key
222        .ok_or(ResolveError::NoIdentityKey)
223}
224
225/// Wraps a source-specific error into [`ResolveError::Chain`] without requiring `S::Error: 'static`.
226fn chain_error<E: core::fmt::Display>(error: E) -> ResolveError {
227    ResolveError::Chain(error.to_string())
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::did::DID_CHIA_PREFIX;
234    use crate::slot::standard;
235    use crate::value::Value;
236    use chia_protocol::Coin;
237    use chia_sdk_utils::Address;
238
239    /// The BLS12-381 G1 identity key a well-formed test profile publishes (slot `0x0010`). Resolution
240    /// only reads the 48 published bytes; it does not curve-validate (the DH path does, §6a.3).
241    const IDENTITY_KEY: [u8; 48] = [7u8; 48];
242
243    /// Encodes a `did:chia:` DID string for `launcher_id` via the canonical bech32m codec.
244    fn did_for(launcher_id: Bytes32) -> String {
245        Address::new(launcher_id, DID_CHIA_PREFIX.to_string())
246            .encode()
247            .unwrap()
248    }
249
250    /// A coin with the given parent. The pairing predicate reads only `parent_coin_info`; a coin's own
251    /// id is `coin_id()`.
252    fn coin(parent: Bytes32) -> Coin {
253        Coin::new(parent, Bytes32::new([9u8; 32]), 1)
254    }
255
256    /// A profile carrying the standard BLS G1 identity key, plus a display name for realism.
257    fn keyed_profile() -> Profile {
258        let mut profile = Profile::with_schema_v2();
259        profile.set(
260            standard::BLS_G1_PUBLIC_KEY,
261            Value::Bytes(IDENTITY_KEY.to_vec()),
262        );
263        profile.set(standard::DISPLAY_NAME, Value::Utf8("Ada".into()));
264        profile
265    }
266
267    fn root_of(profile: &Profile) -> Bytes32 {
268        Bytes32::new(profile.build_root().unwrap())
269    }
270
271    /// An in-memory honest chain view for tests: a configurable DID singleton lineage, candidate
272    /// stores, and the profile body returned by every `fetch_profile`.
273    struct MockSource {
274        lineage: Option<SingletonLineage>,
275        stores: Vec<ChainStoreState>,
276        fetched: Profile,
277        fail: Option<&'static str>,
278    }
279
280    impl MockSource {
281        /// The happy path: one authoritative store launched from the DID's (single-coin) singleton
282        /// lineage tip. Returns the source and the lineage tip coin id.
283        fn authoritative(did_uri: &str) -> (Self, Bytes32) {
284            let did_coin = coin(Bytes32::new([1u8; 32]));
285            let profile = keyed_profile();
286            let store = StoreRecord {
287                description: did_uri.to_string(),
288                launcher_coin: coin(did_coin.coin_id()),
289            };
290            let source = MockSource {
291                lineage: Some(SingletonLineage::single(did_coin.coin_id())),
292                stores: vec![ChainStoreState {
293                    store,
294                    root_hash: root_of(&profile),
295                }],
296                fetched: profile,
297                fail: None,
298            };
299            (source, did_coin.coin_id())
300        }
301    }
302
303    impl ChainSource for MockSource {
304        type Error = &'static str;
305
306        fn resolve_singleton_lineage(
307            &self,
308            _launcher_id: Bytes32,
309        ) -> Result<Option<SingletonLineage>, Self::Error> {
310            match self.fail {
311                Some("tip") => Err("lineage fetch failed"),
312                _ => Ok(self.lineage.clone()),
313            }
314        }
315
316        fn find_stores_for_did(&self, _did: &Did) -> Result<Vec<ChainStoreState>, Self::Error> {
317            match self.fail {
318                Some("stores") => Err("store scan failed"),
319                _ => Ok(self.stores.clone()),
320            }
321        }
322
323        fn fetch_profile(
324            &self,
325            _store: &StoreRecord,
326            _root_hash: Bytes32,
327        ) -> Result<Profile, Self::Error> {
328            match self.fail {
329                Some("fetch") => Err("content fetch failed"),
330                _ => Ok(self.fetched.clone()),
331            }
332        }
333    }
334
335    #[test]
336    fn resolves_keys_round_trip() {
337        let did_uri = did_for(Bytes32::new([42u8; 32]));
338        let (source, _coin_id) = MockSource::authoritative(&did_uri);
339
340        let keys = resolve_did_keys(&did_uri, &source).unwrap();
341        assert_eq!(keys.bls_g1_public_key, Some(IDENTITY_KEY));
342    }
343
344    #[test]
345    fn resolves_bls_public_key_for_engine_resolver() {
346        let did_uri = did_for(Bytes32::new([42u8; 32]));
347        let (source, _) = MockSource::authoritative(&did_uri);
348
349        assert_eq!(
350            resolve_bls_public_key(&did_uri, &source).unwrap(),
351            IDENTITY_KEY
352        );
353    }
354
355    #[test]
356    fn resolved_profile_binds_chain_coin_id_and_root() {
357        let did_uri = did_for(Bytes32::new([42u8; 32]));
358        let (source, coin_id) = MockSource::authoritative(&did_uri);
359
360        let profile = resolve_identity_profile(&did_uri, &source).unwrap();
361        // The singleton coin id is the chain lineage tip, not any producer-supplied value.
362        assert_eq!(profile.singleton().coin_id(), coin_id);
363        assert!(profile.store_belongs_to_did());
364        assert_eq!(profile.root(), keyed_profile().build_root().unwrap());
365    }
366
367    #[test]
368    fn invalid_did_is_rejected() {
369        let (source, _) = MockSource::authoritative("not-a-did");
370        assert_eq!(
371            resolve_did_keys("not-a-did", &source),
372            Err(ResolveError::InvalidDid)
373        );
374    }
375
376    #[test]
377    fn unlaunched_or_melted_singleton_is_no_identity() {
378        let did_uri = did_for(Bytes32::new([42u8; 32]));
379        let (mut source, _) = MockSource::authoritative(&did_uri);
380        source.lineage = None;
381        assert_eq!(
382            resolve_identity_profile(&did_uri, &source),
383            Err(ResolveError::NoIdentitySingleton)
384        );
385    }
386
387    #[test]
388    fn no_candidate_store_is_no_profile() {
389        let did_uri = did_for(Bytes32::new([42u8; 32]));
390        let (mut source, _) = MockSource::authoritative(&did_uri);
391        source.stores.clear();
392        assert_eq!(
393            resolve_identity_profile(&did_uri, &source),
394            Err(ResolveError::NoProfile)
395        );
396    }
397
398    #[test]
399    fn authority_laundering_spoof_is_rejected() {
400        // THE SAFETY PROPERTY. A store that NAMES the victim DID in its description but was launched
401        // from an ATTACKER coin (NOT a member of the victim DID's singleton lineage) must never
402        // resolve -- minting any coin in the victim's lineage requires the victim's key, so the
403        // attacker coin is absent from it. The candidate is discarded and the DID has no profile.
404        let did_uri = did_for(Bytes32::new([42u8; 32]));
405        let (mut source, _) = MockSource::authoritative(&did_uri);
406        let attacker_coin = coin(Bytes32::new([0xEE; 32]));
407        source.stores[0].store.launcher_coin = coin(attacker_coin.coin_id());
408        assert_eq!(
409            resolve_identity_profile(&did_uri, &source),
410            Err(ResolveError::NoProfile)
411        );
412    }
413
414    #[test]
415    fn store_parented_to_past_lineage_coin_is_accepted() {
416        // Authority is lineage MEMBERSHIP, not tip-equality: a store parented to a genuine PAST coin in
417        // the DID singleton's lineage (an earlier tip / the launch-time DID coin) IS authoritative.
418        let did_uri = did_for(Bytes32::new([42u8; 32]));
419        let (mut source, _) = MockSource::authoritative(&did_uri);
420
421        // Lineage launcher -> c1 -> tip; the store was launched from the middle coin `c1`.
422        let launcher = Bytes32::new([0xA0; 32]);
423        let c1 = coin(launcher).coin_id();
424        let tip = coin(c1).coin_id();
425        source.lineage = Some(SingletonLineage::new(tip, [launcher, c1, tip]));
426        source.stores[0].store.launcher_coin = coin(c1);
427
428        let profile = resolve_identity_profile(&did_uri, &source).unwrap();
429        assert_eq!(profile.singleton().coin_id(), tip);
430        assert!(profile.store_belongs_to_did());
431    }
432
433    #[test]
434    fn store_launched_then_did_spent_still_resolves() {
435        // Regression for the WU3 gating bug (#778): launching a store FROM a DID recreates the DID coin
436        // in the SAME spend. The store's launcher parent is the launch-time DID coin `Cn`; that spend
437        // advances the singleton tip to `Cn+1`. With tip-EQUALITY the store would be rejected the
438        // instant it is launched -- breaking EVERY legitimate profile. Lineage MEMBERSHIP accepts it.
439        let did_uri = did_for(Bytes32::new([42u8; 32]));
440        let (mut source, _) = MockSource::authoritative(&did_uri);
441
442        let cn = coin(Bytes32::new([0xB0; 32])).coin_id(); // the DID coin at store-launch spend time
443        let cn_plus_1 = coin(cn).coin_id(); // the DID recreated by that same spend (the new tip)
444        source.lineage = Some(SingletonLineage::new(cn_plus_1, [cn, cn_plus_1]));
445        // The store parents to Cn (the launch-time DID coin), NOT to the current tip Cn+1.
446        source.stores[0].store.launcher_coin = coin(cn);
447
448        let profile = resolve_identity_profile(&did_uri, &source).unwrap();
449        assert_eq!(profile.singleton().coin_id(), cn_plus_1);
450        assert_eq!(profile.keys().bls_g1_public_key, Some(IDENTITY_KEY));
451    }
452
453    #[test]
454    fn two_authoritative_stores_are_ambiguous() {
455        let did_uri = did_for(Bytes32::new([42u8; 32]));
456        let (mut source, _) = MockSource::authoritative(&did_uri);
457        let second = source.stores[0].clone();
458        source.stores.push(second);
459        assert_eq!(
460            resolve_identity_profile(&did_uri, &source),
461            Err(ResolveError::AmbiguousProfile)
462        );
463    }
464
465    #[test]
466    fn stale_or_tampered_root_is_rejected() {
467        let did_uri = did_for(Bytes32::new([42u8; 32]));
468        let (mut source, _) = MockSource::authoritative(&did_uri);
469        // Content that hashes to a DIFFERENT root than the store's committed on-chain root_hash.
470        let mut tampered = keyed_profile();
471        tampered.set(standard::DISPLAY_NAME, Value::Utf8("Mallory".into()));
472        source.fetched = tampered;
473        assert_eq!(
474            resolve_identity_profile(&did_uri, &source),
475            Err(ResolveError::StaleOrTamperedRoot)
476        );
477    }
478
479    #[test]
480    fn missing_identity_key_fails_closed() {
481        let did_uri = did_for(Bytes32::new([42u8; 32]));
482        let (mut source, _) = MockSource::authoritative(&did_uri);
483        let mut no_key = Profile::with_schema_v2();
484        no_key.set(standard::DISPLAY_NAME, Value::Utf8("Ada".into()));
485        source.stores[0].root_hash = root_of(&no_key);
486        source.fetched = no_key;
487        assert_eq!(
488            resolve_bls_public_key(&did_uri, &source),
489            Err(ResolveError::NoIdentityKey)
490        );
491        // resolve_did_keys still succeeds (keys are all-None); only the identity-key accessor fails.
492        assert_eq!(
493            resolve_did_keys(&did_uri, &source)
494                .unwrap()
495                .bls_g1_public_key,
496            None
497        );
498    }
499
500    #[test]
501    fn chain_errors_propagate_at_each_step() {
502        let did_uri = did_for(Bytes32::new([42u8; 32]));
503        for step in ["tip", "stores", "fetch"] {
504            let (mut source, _) = MockSource::authoritative(&did_uri);
505            source.fail = Some(step);
506            match resolve_identity_profile(&did_uri, &source) {
507                Err(ResolveError::Chain(_)) => {}
508                other => panic!("step {step}: expected Chain error, got {other:?}"),
509            }
510        }
511    }
512}