dig_identity/pairing.rs
1//! The DID<->store bidirectional-pairing predicate, as pure types over supplied Chia records.
2//!
3//! A store is the authoritative profile of an identity anchor only when BOTH links hold:
4//!
5//! 1. **Discovery** -- the store's `description` names the DID (`description == the DID string`).
6//! 2. **Authority** -- the store was LAUNCHED FROM the identity singleton, i.e. the store's launcher
7//! coin's PARENT is a genuine coin IN THE DID SINGLETON'S LINEAGE (launch-from-DID lineage --
8//! unforgeable, inherent at launch; no metadata spend, no transfer/ownership layer).
9//!
10//! ## Why authority is lineage MEMBERSHIP, not tip EQUALITY
11//!
12//! A store (or NFT) launched from a DID parents its launcher coin to the DID coin AS IT EXISTED AT
13//! SPEND TIME (`Cn`), and that SAME spend RECREATES the DID singleton, advancing it to `Cn+1`
14//! (chip35's `IntermediateLauncher::new(did.coin.coin_id(), ..)` + `did.update`). So the launcher's
15//! parent is `Cn` while the singleton's CURRENT tip is already `Cn+1` -- they never match. Binding
16//! authority to `== tip` would therefore reject EVERY legitimately-launched profile store. Authority
17//! is instead MEMBERSHIP: the launcher's parent must be a genuine coin in the DID singleton's lineage
18//! (launcher -> tip inclusive). This keeps the security property -- producing ANY coin in the victim
19//! DID's lineage requires the victim's key, so an attacker's coin is never a member and the link stays
20//! unforgeable -- while accepting a store parented to ANY historical DID coin.
21//!
22//! Discovery alone is **forgeable** (anyone can put any DID in their store description), so a
23//! consumer MUST require BOTH links -- description-only is REJECTED. WU1 supplies the predicate over
24//! caller-provided records built from canonical `chia-protocol` types; WU3 wires the chain fetch that
25//! populates the lineage (a walk from the DID launcher to its current tip).
26
27use std::collections::BTreeSet;
28
29use chia_protocol::{Bytes32, Coin};
30
31use crate::did::{parse_did_from_description, Did};
32
33/// The record a caller supplies about a candidate profile store.
34///
35/// WU3 populates these fields from chain reads; WU1 only reasons over them.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct StoreRecord {
38 /// The store's `description` field (the discovery channel -- expected to be the DID string).
39 pub description: String,
40 /// The store's launcher coin. Its `parent_coin_info` is the authority channel (must be a coin in
41 /// the identity singleton's lineage for launch-from-DID lineage); its `coin_id()` is the
42 /// launcher id.
43 pub launcher_coin: Coin,
44}
45
46impl StoreRecord {
47 /// The store's launcher id (the launcher coin's id).
48 pub fn launcher_id(&self) -> Bytes32 {
49 self.launcher_coin.coin_id()
50 }
51}
52
53/// The lineage of a DID identity singleton: every coin id from the launcher spend forward to the
54/// current unspent tip.
55///
56/// Authority is MEMBERSHIP in this lineage, not equality with the tip (see the module docs): a store
57/// launched from ANY genuine DID coin -- the launch-time coin `Cn`, later spent to `Cn+1` -- is
58/// authoritative, while an attacker's coin (never a member, since minting any lineage coin requires
59/// the DID's key) is not. A conforming WU3 [`crate::resolve::ChainSource`] populates this by walking
60/// the singleton lineage on-chain.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct SingletonLineage {
63 /// The current unspent singleton tip coin id (the DID's current on-chain state handle).
64 tip: Bytes32,
65 /// Every coin id in the lineage (launcher -> tip inclusive). Always contains `tip`.
66 members: BTreeSet<Bytes32>,
67}
68
69impl SingletonLineage {
70 /// Builds a lineage from its full member set and current `tip`. `tip` is always treated as a
71 /// member, so a caller need not include it in `members` explicitly.
72 pub fn new(tip: Bytes32, members: impl IntoIterator<Item = Bytes32>) -> Self {
73 let mut members: BTreeSet<Bytes32> = members.into_iter().collect();
74 members.insert(tip);
75 Self { tip, members }
76 }
77
78 /// A degenerate single-coin lineage (the tip is the only member).
79 ///
80 /// Use ONLY for a DID that has never been spent since launch, or where the caller genuinely knows
81 /// no other lineage coin. It reproduces the strict tip-only authority behaviour, so a store
82 /// parented to an earlier coin will NOT match -- prefer [`Self::new`] with the walked lineage.
83 pub fn single(tip: Bytes32) -> Self {
84 Self::new(tip, [tip])
85 }
86
87 /// The current unspent singleton tip coin id.
88 pub fn tip(&self) -> Bytes32 {
89 self.tip
90 }
91
92 /// Whether `coin_id` is a genuine coin in this singleton's lineage -- the authority membership test.
93 pub fn contains(&self, coin_id: Bytes32) -> bool {
94 self.members.contains(&coin_id)
95 }
96}
97
98/// The identity anchor a store claims to belong to.
99///
100/// The anchor is abstracted as a singleton (a `did:chia:` DID in v1, vault-capable later); `lineage`
101/// is the singleton's coin lineage, one member of which an authoritative store's launcher parent must
102/// equal.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct IdentitySingleton {
105 /// The identity anchor's DID.
106 pub did: Did,
107 /// The identity singleton's lineage (launcher -> tip). An authoritative store's launcher parent
108 /// must be a MEMBER of this lineage (launched from SOME genuine DID coin), never merely the tip.
109 pub lineage: SingletonLineage,
110}
111
112impl IdentitySingleton {
113 /// The identity singleton's current unspent tip coin id (its current on-chain state handle).
114 pub fn coin_id(&self) -> Bytes32 {
115 self.lineage.tip()
116 }
117}
118
119/// The result of evaluating the pairing predicate -- each link reported independently.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct PairingOutcome {
122 /// `true` when the store description parses to a DID equal to the singleton's DID.
123 pub discovery_matches: bool,
124 /// `true` when the store's launcher parent is a member of the identity singleton's lineage.
125 pub authority_matches: bool,
126}
127
128impl PairingOutcome {
129 /// The store is the DID's authoritative profile ONLY when BOTH links hold.
130 ///
131 /// This is the single decision a consumer should gate on; the individual booleans exist for
132 /// diagnostics (e.g. "description matched but lineage did not -- likely a spoof").
133 pub fn is_authoritative(self) -> bool {
134 self.discovery_matches && self.authority_matches
135 }
136}
137
138/// Evaluates both pairing links between `store` and `singleton`, reporting each independently.
139pub fn evaluate_pairing(store: &StoreRecord, singleton: &IdentitySingleton) -> PairingOutcome {
140 let discovery_matches =
141 parse_did_from_description(&store.description).is_some_and(|did| did == singleton.did);
142 let authority_matches = singleton
143 .lineage
144 .contains(store.launcher_coin.parent_coin_info);
145 PairingOutcome {
146 discovery_matches,
147 authority_matches,
148 }
149}
150
151/// Returns `true` iff `store` is the authoritative profile of `singleton` (BOTH links required).
152///
153/// The mandated consumer entry point: it is impossible to accept a store on discovery alone.
154pub fn is_authoritative_profile(store: &StoreRecord, singleton: &IdentitySingleton) -> bool {
155 evaluate_pairing(store, singleton).is_authoritative()
156}
157
158/// Returns `true` iff the chip35 DataLayer `store` belongs to the identity `singleton`.
159///
160/// The domain-named form of [`is_authoritative_profile`], answering the question consumers ask
161/// verbatim -- "does this store belong to this DID?". It holds IFF BOTH links of the pairing
162/// predicate hold: the store's `description` names the DID (discovery) AND the store's launcher coin
163/// was launched from a coin in the DID singleton's lineage (launch-from-DID lineage). Description-only
164/// or lineage-only returns `false`.
165///
166/// **Trust boundary:** this is sound ONLY RELATIVE TO a `singleton.lineage` the caller has resolved
167/// on-chain (WU3) as `did.launcher_id`'s authentic singleton lineage. `lineage` is caller-supplied and
168/// unauthenticated here -- an attacker may pass their OWN singleton's lineage and, with a store they
169/// launched from it whose description names the victim DID, obtain a `true`. Never pass a
170/// producer-supplied `lineage`.
171pub fn store_belongs_to_did(store: &StoreRecord, singleton: &IdentitySingleton) -> bool {
172 is_authoritative_profile(store, singleton)
173}
174
175/// A convenience bundle of the `(singleton, store)` records the pairing predicate runs over.
176///
177/// **NOT a self-authenticating, trustless proof.** [`StoreOwnershipProof::verify`] re-runs the section 7
178/// predicate -- it confirms the discovery link (`store.description == did:chia:<launcher_id>`) AND the
179/// authority link (`store.launcher_coin.parent_coin_info` is a member of `singleton.lineage`) -- but
180/// that decision is SOUND ONLY RELATIVE TO a `singleton.lineage` the verifier has INDEPENDENTLY
181/// resolved on-chain (WU3) as `did.launcher_id`'s authentic singleton lineage.
182///
183/// Both `singleton.did` and `singleton.lineage` are independent, caller-supplied fields with NO
184/// internal binding: nothing here checks that `lineage` is the DID's real singleton lineage. A producer
185/// may therefore supply ANY lineage -- e.g. their own singleton's -- so a store they launched
186/// themselves passes `verify()` against a victim's DID. Consuming this bundle from an UNTRUSTED
187/// producer is a spoofing trap: `verify() == true` means only "these two records satisfy the
188/// predicate", not "this store is chain-authenticated as the DID's profile".
189///
190/// The trustworthy portable proof -- one whose `lineage` is chain-bound to the DID -- is WU3's job. Use
191/// this type only when YOU have resolved `singleton.lineage` on-chain yourself.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct StoreOwnershipProof {
194 /// The identity singleton the store claims to belong to. `lineage` MUST be the verifier's own
195 /// on-chain-resolved singleton lineage for `did` (WU3) -- it is NOT authenticated by [`Self::verify`].
196 pub singleton: IdentitySingleton,
197 /// The candidate profile store record.
198 pub store: StoreRecord,
199}
200
201impl StoreOwnershipProof {
202 /// Bundles a `(singleton, store)` pair. Does NOT authenticate `singleton.lineage` -- see the type
203 /// doc: the caller MUST have resolved `lineage` on-chain (WU3) before trusting [`Self::verify`].
204 pub fn new(singleton: IdentitySingleton, store: StoreRecord) -> Self {
205 StoreOwnershipProof { singleton, store }
206 }
207
208 /// Re-evaluates both pairing links, reporting each independently (for diagnostics).
209 pub fn outcome(&self) -> PairingOutcome {
210 evaluate_pairing(&self.store, &self.singleton)
211 }
212
213 /// Re-runs the section 7 pairing predicate over the bundled records (BOTH links).
214 ///
215 /// Returns `true` iff discovery AND authority hold -- but ONLY sound when `singleton.lineage` was
216 /// verifier-resolved on-chain as the DID's authentic singleton lineage (see the type doc). A
217 /// `true` from an untrusted producer does NOT prove chain ownership.
218 pub fn verify(&self) -> bool {
219 store_belongs_to_did(&self.store, &self.singleton)
220 }
221}