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}