Skip to main content

dig_urn_protocol/
verify.rs

1//! The browser content-VERIFICATION contract — how a blind client turns opaque gateway bytes into
2//! verified plaintext, **fail-closed**, over INJECTED crypto primitives.
3//!
4//! # Trust model
5//!
6//! On the blind (rpc/gateway) tier a client fetches opaque ciphertext + an inclusion proof from an
7//! UNTRUSTED public gateway. The gateway can lie about anything except what the chain anchors, so the
8//! client MUST verify every byte against the URN's PINNED root (obtained from the chain, NEVER from
9//! the gateway) before trusting it. The node tier does NOT use this contract — a loopback node
10//! decrypts + verifies server-side and returns plaintext under a loopback trust boundary.
11//!
12//! # The normative rules (all enforced here, fail-closed)
13//!
14//! 1. **Rootless-URN rejection.** A rootless URN cannot be verified on the blind tier (there is no
15//!    trusted root) → [`ResolveError::RootRequired`]. Use [`require_blind_root`].
16//! 2. **Leaf binding.** `leaf == SHA-256(ciphertext)` — the served ciphertext MUST be the proof's
17//!    declared leaf. (This crate owns this SHA-256 check; see [`resource_leaf`].)
18//! 3. **Path fold.** The proof's merkle path MUST fold consistently to `proof.root` — enforced by the
19//!    injected [`ContentCrypto::decode_and_fold`] (returns `None` on any inconsistency).
20//! 4. **Root anchoring.** `proof.root == trusted_root` — the folded root MUST equal the
21//!    chain-anchored root pinned by the URN. A decoy / wrong-store / tampered response can never
22//!    chain to the real root.
23//! 5. **Gate-then-decrypt.** Decryption happens ONLY after 1–4 pass; the AEAD tag is the final gate.
24//! 6. **u64-bounded chunk split.** The gateway-supplied `chunk_lens` is NOT covered by the proof, so
25//!    it is UNTRUSTED: it is accumulated and bounded in `u64` and sliced against the remaining buffer
26//!    so a crafted length can never wrap `usize` on wasm32 and slice out of bounds (→ `panic=abort`,
27//!    a wallet crash). Any inconsistency fails closed as [`ResolveError::DecryptFailed`].
28//!
29//! The merkle-fold and AES primitives are supplied by the caller (`digstore_core`) via
30//! [`ContentCrypto`] — this crate reimplements NO merkle or AES crypto, so it can never skew from
31//! the canonical read-crypto.
32
33use crate::bytes::Bytes32;
34use crate::resolve::{ResolveError, Result};
35use crate::urn::{DigUrn, SecretSalt};
36use sha2::{Digest, Sha256};
37
38/// A decoded, folded inclusion proof: its declared leaf and the root its merkle path folds to.
39///
40/// The injected [`ContentCrypto::decode_and_fold`] produces this from the wire proof, returning
41/// `Some` ONLY when the path folds consistently to a single root (rule 3). The remaining equalities
42/// (leaf-binding, root-anchoring) are enforced by [`verify_inclusion`].
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct FoldedProof {
45    /// The leaf the proof declares (MUST equal `SHA-256(ciphertext)` — rule 2).
46    pub leaf: Bytes32,
47    /// The root the proof's path folds to (MUST equal the chain-anchored `trusted_root` — rule 4).
48    pub root: Bytes32,
49}
50
51/// The crypto primitives this contract INJECTS from `digstore_core` (never reimplemented here).
52pub trait ContentCrypto {
53    /// Decode the wire-encoded inclusion `proof` and fold its merkle path, returning the declared
54    /// leaf and folded root. Return `None` on ANY malformed encoding or internally-inconsistent path
55    /// (fail-closed) — the path-fold rule (3) lives here.
56    fn decode_and_fold(&self, proof: &[u8]) -> Option<FoldedProof>;
57
58    /// AES-256-GCM-SIV open ONE ciphertext `chunk` under the key derived from `urn`'s rootless
59    /// canonical form + optional `salt`. Return `None` on an AEAD tag failure (fail-closed).
60    fn decrypt_chunk(
61        &self,
62        urn: &DigUrn,
63        salt: Option<&SecretSalt>,
64        chunk: &[u8],
65    ) -> Option<Vec<u8>>;
66}
67
68/// The content leaf: `SHA-256(ciphertext)` (rule 2). This is the only crypto this leaf crate
69/// performs directly; it matches `digstore_core::resource_leaf`.
70pub fn resource_leaf(ciphertext: &[u8]) -> Bytes32 {
71    let mut hasher = Sha256::new();
72    hasher.update(ciphertext);
73    Bytes32(hasher.finalize().into())
74}
75
76/// Rule 1: obtain the trusted root for a BLIND-tier verify, rejecting a rootless URN.
77///
78/// The root MUST come from the chain (the caller passes what it read from the anchor), NEVER from the
79/// gateway. A URN with no pinned root cannot be verified blind → [`ResolveError::RootRequired`].
80pub fn require_blind_root(urn: &DigUrn) -> Result<Bytes32> {
81    urn.root_hash.ok_or(ResolveError::RootRequired)
82}
83
84/// Rules 2–4: the integrity gate. The served `ciphertext` must be the proof's leaf, the path must
85/// fold to a root (via the injected decoder), and that root must equal `trusted_root`. Any failure is
86/// a hard fail-closed [`ResolveError::VerifyFailed`].
87pub fn verify_inclusion<C: ContentCrypto>(
88    crypto: &C,
89    ciphertext: &[u8],
90    proof: &[u8],
91    trusted_root: &Bytes32,
92) -> Result<()> {
93    let folded = crypto.decode_and_fold(proof).ok_or_else(|| {
94        ResolveError::VerifyFailed("inclusion proof is malformed or inconsistent".into())
95    })?;
96
97    if folded.leaf != resource_leaf(ciphertext) {
98        return Err(ResolveError::VerifyFailed(
99            "content does not match proof leaf (tampered ciphertext)".into(),
100        ));
101    }
102    if &folded.root != trusted_root {
103        return Err(ResolveError::VerifyFailed(
104            "merkle root does not match the chain-anchored trusted root".into(),
105        ));
106    }
107    Ok(())
108}
109
110/// Rule 6: split concatenated chunk ciphertexts into byte ranges under a u64-bounded plan.
111///
112/// `chunk_lens` is the UNTRUSTED per-chunk ciphertext byte lengths in order (no wire framing). An
113/// empty plan is the common single-chunk resource (`[ciphertext.len()]`). The lengths are summed and
114/// bounded in `u64` (never `usize`, so no wasm32 wrap), the total must equal the buffer length, and
115/// each window is sliced defensively against the remaining buffer. Any inconsistency →
116/// [`ResolveError::DecryptFailed`], never a panic.
117pub fn chunk_ranges(ciphertext_len: usize, chunk_lens: &[u32]) -> Result<Vec<(usize, usize)>> {
118    let ct_len = ciphertext_len as u64;
119    let plan: Vec<u64> = if chunk_lens.is_empty() {
120        vec![ct_len]
121    } else {
122        chunk_lens.iter().map(|&l| l as u64).collect()
123    };
124
125    let mut total: u64 = 0;
126    for &len in &plan {
127        total = total.checked_add(len).ok_or(ResolveError::DecryptFailed)?;
128    }
129    if total != ct_len {
130        return Err(ResolveError::DecryptFailed);
131    }
132
133    let mut ranges = Vec::with_capacity(plan.len());
134    let mut start: usize = 0;
135    for len in plan {
136        let len = usize::try_from(len).map_err(|_| ResolveError::DecryptFailed)?;
137        let end = start
138            .checked_add(len)
139            .filter(|&e| e <= ciphertext_len)
140            .ok_or(ResolveError::DecryptFailed)?;
141        ranges.push((start, end));
142        start = end;
143    }
144    Ok(ranges)
145}
146
147/// Rule 5 (confidentiality half): decrypt the verified ciphertext. Splits by [`chunk_ranges`] and
148/// AES-opens each chunk in order via the injected [`ContentCrypto::decrypt_chunk`]. A tag failure on
149/// any chunk fails closed with [`ResolveError::DecryptFailed`].
150pub fn decrypt<C: ContentCrypto>(
151    crypto: &C,
152    urn: &DigUrn,
153    salt: Option<&SecretSalt>,
154    ciphertext: &[u8],
155    chunk_lens: &[u32],
156) -> Result<Vec<u8>> {
157    let mut plaintext = Vec::with_capacity(ciphertext.len());
158    for (start, end) in chunk_ranges(ciphertext.len(), chunk_lens)? {
159        let chunk = &ciphertext[start..end];
160        let pt = crypto
161            .decrypt_chunk(urn, salt, chunk)
162            .ok_or(ResolveError::DecryptFailed)?;
163        plaintext.extend_from_slice(&pt);
164    }
165    Ok(plaintext)
166}
167
168/// The full blind-tier pipeline: **gate-then-decrypt** (rules 1–6). Rejects a rootless URN, verifies
169/// inclusion against `trusted_root`, then decrypts — decryption is reached ONLY after verification
170/// passes.
171pub fn verify_and_decrypt<C: ContentCrypto>(
172    crypto: &C,
173    urn: &DigUrn,
174    salt: Option<&SecretSalt>,
175    ciphertext: &[u8],
176    proof: &[u8],
177    trusted_root: &Bytes32,
178    chunk_lens: &[u32],
179) -> Result<Vec<u8>> {
180    verify_inclusion(crypto, ciphertext, proof, trusted_root)?;
181    decrypt(crypto, urn, salt, ciphertext, chunk_lens)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// A test double: XOR "decrypts", and folds a proof whose bytes are `leaf(32) || root(32)`.
189    struct FakeCrypto;
190    impl ContentCrypto for FakeCrypto {
191        fn decode_and_fold(&self, proof: &[u8]) -> Option<FoldedProof> {
192            if proof.len() != 64 {
193                return None;
194            }
195            let mut leaf = [0u8; 32];
196            let mut root = [0u8; 32];
197            leaf.copy_from_slice(&proof[..32]);
198            root.copy_from_slice(&proof[32..]);
199            Some(FoldedProof {
200                leaf: Bytes32(leaf),
201                root: Bytes32(root),
202            })
203        }
204        fn decrypt_chunk(
205            &self,
206            _urn: &DigUrn,
207            _salt: Option<&SecretSalt>,
208            chunk: &[u8],
209        ) -> Option<Vec<u8>> {
210            Some(chunk.iter().map(|b| b ^ 0xAA).collect())
211        }
212    }
213
214    fn urn() -> DigUrn {
215        DigUrn::parse(&format!("urn:dig:chia:{}/a.bin", "11".repeat(32))).unwrap()
216    }
217
218    fn valid_proof(ciphertext: &[u8], root: &Bytes32) -> Vec<u8> {
219        let mut p = resource_leaf(ciphertext).0.to_vec();
220        p.extend_from_slice(&root.0);
221        p
222    }
223
224    #[test]
225    fn require_blind_root_rejects_rootless() {
226        assert_eq!(require_blind_root(&urn()), Err(ResolveError::RootRequired));
227        let rooted = DigUrn::parse(&format!(
228            "urn:dig:chia:{}:{}/a",
229            "11".repeat(32),
230            "22".repeat(32)
231        ))
232        .unwrap();
233        assert!(require_blind_root(&rooted).is_ok());
234    }
235
236    #[test]
237    fn verify_and_decrypt_happy_path() {
238        let ct = vec![0x01u8; 8];
239        let root = Bytes32([0x33u8; 32]);
240        let proof = valid_proof(&ct, &root);
241        let out = verify_and_decrypt(&FakeCrypto, &urn(), None, &ct, &proof, &root, &[]).unwrap();
242        assert_eq!(out, vec![0x01 ^ 0xAA; 8]);
243    }
244
245    #[test]
246    fn tampered_ciphertext_fails_leaf_binding() {
247        let ct = vec![0x01u8; 8];
248        let root = Bytes32([0x33u8; 32]);
249        let proof = valid_proof(&ct, &root);
250        let tampered = vec![0x02u8; 8];
251        assert!(matches!(
252            verify_inclusion(&FakeCrypto, &tampered, &proof, &root),
253            Err(ResolveError::VerifyFailed(_))
254        ));
255    }
256
257    #[test]
258    fn wrong_trusted_root_fails_anchoring() {
259        let ct = vec![0x01u8; 8];
260        let proof = valid_proof(&ct, &Bytes32([0x33u8; 32]));
261        let other_root = Bytes32([0x44u8; 32]);
262        assert!(matches!(
263            verify_inclusion(&FakeCrypto, &ct, &proof, &other_root),
264            Err(ResolveError::VerifyFailed(_))
265        ));
266    }
267
268    #[test]
269    fn malformed_proof_fails_closed() {
270        let ct = vec![0x01u8; 8];
271        assert!(matches!(
272            verify_inclusion(&FakeCrypto, &ct, &[0u8; 10], &Bytes32([0x33u8; 32])),
273            Err(ResolveError::VerifyFailed(_))
274        ));
275    }
276
277    #[test]
278    fn chunk_ranges_empty_is_single_chunk() {
279        assert_eq!(chunk_ranges(10, &[]).unwrap(), vec![(0, 10)]);
280    }
281
282    #[test]
283    fn chunk_ranges_splits_in_order() {
284        assert_eq!(chunk_ranges(10, &[3, 7]).unwrap(), vec![(0, 3), (3, 10)]);
285    }
286
287    #[test]
288    fn chunk_ranges_rejects_total_mismatch() {
289        assert_eq!(chunk_ranges(10, &[999]), Err(ResolveError::DecryptFailed));
290    }
291
292    #[test]
293    fn chunk_ranges_rejects_overflow_without_panic() {
294        // `[len+2^31, 2^31]` wraps usize on wasm32; the u64-checked total must reject it cleanly.
295        let bad = [(1u32 << 31) + 10, 1u32 << 31];
296        assert_eq!(chunk_ranges(10, &bad), Err(ResolveError::DecryptFailed));
297    }
298
299    #[test]
300    fn decrypt_maps_tag_failure_to_decryptfailed() {
301        struct AlwaysFail;
302        impl ContentCrypto for AlwaysFail {
303            fn decode_and_fold(&self, _p: &[u8]) -> Option<FoldedProof> {
304                None
305            }
306            fn decrypt_chunk(
307                &self,
308                _u: &DigUrn,
309                _s: Option<&SecretSalt>,
310                _c: &[u8],
311            ) -> Option<Vec<u8>> {
312                None
313            }
314        }
315        assert_eq!(
316            decrypt(&AlwaysFail, &urn(), None, &[0u8; 4], &[]),
317            Err(ResolveError::DecryptFailed)
318        );
319    }
320}