Skip to main content

dig_urn_protocol/
urn.rs

1//! [`DigUrn`] — the parsed `urn:dig:` scheme, its canonical form, and retrieval-key derivation.
2//!
3//! This is the source-of-truth implementation the [`crate::grammar`] ABNF describes; the frozen
4//! conformance vectors (`tests/fixtures/urn_conformance.json`) prove the two agree.
5//!
6//! Format: `urn:dig:<chain>:<store-id>[:<root-hash>][/<resource>][?salt=<hex>]`
7//! - `retrieval_key = SHA-256(canonical())` (URN identity; pins the root)
8//! - `content_key   = SHA-256(canonical_rootless())` (root-independent fetch/AES-seed key)
9
10use crate::bytes::Bytes32;
11use crate::grammar::{DEFAULT_RESOURCE_KEY, SALT_QUERY_MARKER, URN_PREFIX};
12use sha2::{Digest, Sha256};
13
14/// SHA-256 of `data` as a [`Bytes32`].
15fn sha256_hex(data: &[u8]) -> Bytes32 {
16    let mut hasher = Sha256::new();
17    hasher.update(data);
18    Bytes32(hasher.finalize().into())
19}
20
21/// A private-store secret salt: 32 bytes of out-of-band key material.
22///
23/// NOT part of the URN identity (see [`crate::grammar`]) — it is a separate input to key derivation
24/// so the host, which sees only the retrieval key, cannot distinguish a private store from a public
25/// one. A surfaced salt MUST be exactly 32 bytes / 64 lowercase hex.
26#[derive(Clone, Copy, PartialEq, Eq)]
27pub struct SecretSalt(pub [u8; 32]);
28
29impl core::fmt::Debug for SecretSalt {
30    /// Never render the salt bytes — it is key material.
31    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
32        f.write_str("SecretSalt(<redacted>)")
33    }
34}
35
36/// A parsed DIG URN.
37///
38/// The `resource_key` distinguishes three states: `None` (absent — a bare store/root URN),
39/// `Some("")` (a trailing slash), and `Some("path")` (a concrete resource). All three are valid;
40/// [`DigUrn::effective_resource_key`] maps the first two to the [`DEFAULT_RESOURCE_KEY`].
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DigUrn {
43    /// The chain label. Canonical value is `chia`; `mainnet`/`testnet` are accepted for back-compat.
44    pub chain: String,
45    /// The CHIP-0035 singleton launcher id (store identity).
46    pub store_id: Bytes32,
47    /// The pinned on-chain generation root. `None` = the root-independent form. The root is the
48    /// trust anchor for inclusion verification ONLY; it is never a key input.
49    pub root_hash: Option<Bytes32>,
50    /// The resource path within the store, verbatim after the FIRST `/`. See the struct docs for the
51    /// three-state distinction.
52    pub resource_key: Option<String>,
53}
54
55/// A parse failure with a stable, human-readable reason.
56#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
57#[error("invalid DIG URN: {0}")]
58pub struct UrnParseError(pub String);
59
60impl DigUrn {
61    /// Parse a canonical URN string (no `?salt=` handling — use [`DigUrn::parse_with_salt`] to peel a
62    /// salt suffix first). Accepts an omitted root-hash and/or resource.
63    pub fn parse(input: &str) -> Result<DigUrn, UrnParseError> {
64        let rest = input
65            .strip_prefix(URN_PREFIX)
66            .ok_or_else(|| UrnParseError(format!("missing '{URN_PREFIX}' prefix")))?;
67
68        // Split off the optional resource path at the FIRST '/'.
69        let (head, resource_key) = match rest.split_once('/') {
70            Some((h, r)) => (h, Some(r.to_string())),
71            None => (rest, None),
72        };
73
74        // head = <chain>:<store-id>[:<root-hash>]
75        let mut parts = head.split(':');
76        let chain = parts
77            .next()
78            .filter(|c| !c.is_empty())
79            .ok_or_else(|| UrnParseError("missing chain".into()))?
80            .to_string();
81        let store_id_hex = parts
82            .next()
83            .ok_or_else(|| UrnParseError("missing store id".into()))?;
84        let store_id = Bytes32::from_hex(store_id_hex)
85            .map_err(|_| UrnParseError("store id must be 64 hex chars".into()))?;
86        let root_hash = match parts.next() {
87            Some(rh) => Some(
88                Bytes32::from_hex(rh)
89                    .map_err(|_| UrnParseError("root hash must be 64 hex chars".into()))?,
90            ),
91            None => None,
92        };
93        if parts.next().is_some() {
94            return Err(UrnParseError("too many ':' segments".into()));
95        }
96
97        Ok(DigUrn {
98            chain,
99            store_id,
100            root_hash,
101            resource_key,
102        })
103    }
104
105    /// Parse a URN, peeling an OPTIONAL `?salt=<hex>` suffix off the tail first.
106    ///
107    /// The salt is validated as non-empty hex (case-insensitive, normalised to lowercase) and
108    /// returned separately; the remainder is parsed by [`DigUrn::parse`]. A conforming validator that
109    /// surfaces the salt MUST require exactly 64 hex chars — this function accepts any non-empty hex
110    /// and leaves the 32-byte enforcement to [`DigUrn::salt_bytes`].
111    pub fn parse_with_salt(input: &str) -> Result<(DigUrn, Option<String>), UrnParseError> {
112        let trimmed = input.trim();
113        let (core_part, salt) = match trimmed.rsplit_once(SALT_QUERY_MARKER) {
114            Some((head, salt_hex)) => {
115                let salt_hex = salt_hex.trim();
116                if salt_hex.is_empty() || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
117                    return Err(UrnParseError(format!(
118                        "{SALT_QUERY_MARKER} must be non-empty hex"
119                    )));
120                }
121                (head, Some(salt_hex.to_ascii_lowercase()))
122            }
123            None => (trimmed, None),
124        };
125        Ok((DigUrn::parse(core_part)?, salt))
126    }
127
128    /// Validate a peeled salt hex string into a [`SecretSalt`] — exactly 32 bytes / 64 hex.
129    pub fn salt_bytes(salt_hex: &str) -> Result<SecretSalt, UrnParseError> {
130        Bytes32::from_hex(salt_hex.trim())
131            .map(|b| SecretSalt(b.0))
132            .map_err(|_| UrnParseError("secret salt must be 64 hex chars".into()))
133    }
134
135    /// Render the canonical URN string.
136    pub fn canonical(&self) -> String {
137        let mut s = format!("{URN_PREFIX}{}:{}", self.chain, self.store_id.to_hex());
138        if let Some(rh) = &self.root_hash {
139            s.push(':');
140            s.push_str(&rh.to_hex());
141        }
142        if let Some(rk) = &self.resource_key {
143            s.push('/');
144            s.push_str(rk);
145        }
146        s
147    }
148
149    /// The resource path, defaulting an absent or empty key to [`DEFAULT_RESOURCE_KEY`].
150    pub fn effective_resource_key(&self) -> &str {
151        match self.resource_key.as_deref() {
152            Some(k) if !k.is_empty() => k,
153            _ => DEFAULT_RESOURCE_KEY,
154        }
155    }
156
157    /// The canonical ROOT-INDEPENDENT resource URN. Dropping the root keeps the retrieval and
158    /// decryption keys stable across generations (matching the host/CLI commit-time derivation), and
159    /// carries the [`DigUrn::effective_resource_key`] (empty/absent → `index.html`).
160    pub fn canonical_rootless(&self) -> DigUrn {
161        DigUrn {
162            chain: self.chain.clone(),
163            store_id: self.store_id,
164            root_hash: None,
165            resource_key: Some(self.effective_resource_key().to_string()),
166        }
167    }
168
169    /// The URN-identity retrieval key: `SHA-256(canonical())` — over the FULL canonical form
170    /// (including the pinned root, if any). This is the value the frozen conformance corpus pins and
171    /// that `digstore_core::Urn::retrieval_key` derives; it is a property of the URN string.
172    pub fn retrieval_key(&self) -> Bytes32 {
173        sha256_hex(self.canonical().as_bytes())
174    }
175
176    /// `retrieval_key` as lowercase hex.
177    pub fn retrieval_key_hex(&self) -> String {
178        self.retrieval_key().to_hex()
179    }
180
181    /// The ROOT-INDEPENDENT content key: `SHA-256(canonical_rootless())`. Stable across generations,
182    /// it is the identifier a resolver uses to FETCH content and the seed for the AES key derivation
183    /// (matching the host/CLI commit-time derivation) — distinct from [`DigUrn::retrieval_key`],
184    /// which pins the root.
185    pub fn content_key(&self) -> Bytes32 {
186        sha256_hex(self.canonical_rootless().canonical().as_bytes())
187    }
188
189    /// `content_key` as lowercase hex.
190    pub fn content_key_hex(&self) -> String {
191        self.content_key().to_hex()
192    }
193
194    /// The store id as lowercase hex.
195    pub fn store_id_hex(&self) -> String {
196        self.store_id.to_hex()
197    }
198
199    /// The pinned generation root as lowercase hex, if the URN carries one.
200    pub fn root_hex(&self) -> Option<String> {
201        self.root_hash.map(|r| r.to_hex())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn store() -> String {
210        "11".repeat(32)
211    }
212
213    #[test]
214    fn parses_full_form_and_canonicalises_idempotently() {
215        let input = format!("urn:dig:chia:{}:{}/index.html", store(), "22".repeat(32));
216        let urn = DigUrn::parse(&input).unwrap();
217        assert_eq!(urn.chain, "chia");
218        assert_eq!(urn.root_hash.unwrap().to_hex(), "22".repeat(32));
219        assert_eq!(urn.resource_key.as_deref(), Some("index.html"));
220        assert_eq!(urn.canonical(), input);
221    }
222
223    #[test]
224    fn bare_store_has_no_resource_and_defaults_to_index() {
225        let urn = DigUrn::parse(&format!("urn:dig:chia:{}", store())).unwrap();
226        assert_eq!(urn.resource_key, None);
227        assert_eq!(urn.effective_resource_key(), "index.html");
228    }
229
230    #[test]
231    fn trailing_slash_is_empty_resource_distinct_from_absent() {
232        let urn = DigUrn::parse(&format!("urn:dig:chia:{}/", store())).unwrap();
233        assert_eq!(urn.resource_key.as_deref(), Some(""));
234        assert_eq!(urn.effective_resource_key(), "index.html");
235    }
236
237    #[test]
238    fn resource_split_is_at_first_slash() {
239        let urn = DigUrn::parse(&format!("urn:dig:chia:{}/a/b/c.json", store())).unwrap();
240        assert_eq!(urn.resource_key.as_deref(), Some("a/b/c.json"));
241    }
242
243    #[test]
244    fn retrieval_key_pins_the_root_but_content_key_is_root_independent() {
245        let rootless = DigUrn::parse(&format!("urn:dig:chia:{}/a", store())).unwrap();
246        let rooted =
247            DigUrn::parse(&format!("urn:dig:chia:{}:{}/a", store(), "22".repeat(32))).unwrap();
248        // retrieval_key = SHA-256(canonical) DIFFERS once a root is pinned (frozen-corpus rule)...
249        assert_ne!(rootless.retrieval_key(), rooted.retrieval_key());
250        // ...while content_key = SHA-256(canonical_rootless) stays stable across generations.
251        assert_eq!(rootless.content_key(), rooted.content_key());
252    }
253
254    #[test]
255    fn accepts_mainnet_and_testnet_labels_for_backcompat() {
256        assert!(DigUrn::parse(&format!("urn:dig:mainnet:{}/a", store())).is_ok());
257        assert!(DigUrn::parse(&format!("urn:dig:testnet:{}", store())).is_ok());
258    }
259
260    #[test]
261    fn rejects_bad_forms() {
262        assert!(DigUrn::parse("urn:other:chia:00").is_err());
263        assert!(DigUrn::parse("not-a-urn").is_err());
264        assert!(DigUrn::parse("urn:dig:chia").is_err());
265        assert!(DigUrn::parse(&format!("urn:dig::{}", store())).is_err());
266        assert!(DigUrn::parse("urn:dig:chia:zzzz").is_err());
267        assert!(DigUrn::parse(&format!(
268            "urn:dig:chia:{}:{}:{}",
269            store(),
270            "22".repeat(32),
271            "33".repeat(32)
272        ))
273        .is_err());
274    }
275
276    #[test]
277    fn peels_salt_suffix_and_leaves_it_out_of_identity() {
278        let with_salt = format!("urn:dig:chia:{}/index.html?salt=DEADBEEF", store());
279        let (urn, salt) = DigUrn::parse_with_salt(&with_salt).unwrap();
280        assert_eq!(salt.as_deref(), Some("deadbeef")); // normalised lowercase
281        assert_eq!(urn.resource_key.as_deref(), Some("index.html"));
282    }
283
284    #[test]
285    fn core_parser_leaves_salt_query_inside_resource() {
286        // Without peeling, the ?salt suffix is part of the resource (frozen-corpus rule).
287        let urn = DigUrn::parse(&format!(
288            "urn:dig:chia:{}/index.html?salt=deadbeef",
289            store()
290        ))
291        .unwrap();
292        assert_eq!(
293            urn.resource_key.as_deref(),
294            Some("index.html?salt=deadbeef")
295        );
296    }
297
298    #[test]
299    fn salt_bytes_requires_64_hex() {
300        assert!(DigUrn::salt_bytes(&"ab".repeat(32)).is_ok());
301        assert!(DigUrn::salt_bytes("deadbeef").is_err());
302    }
303
304    #[test]
305    fn empty_salt_query_rejected() {
306        assert!(DigUrn::parse_with_salt(&format!("urn:dig:chia:{}/a?salt=", store())).is_err());
307    }
308}