dig-urn-resolver 0.3.1

Resolve a DIG URN to its data through the protocol (node-first ladder, verified + decrypted). Rust + wasm; first consumer: Sage wallet NFT images.
Documentation
//! The rpc-path read-crypto — a thin orchestration over `digstore_core`.
//!
//! Every primitive here (URN key derivation, AES-256-GCM-SIV open, merkle
//! inclusion verify) is `digstore_core`'s — the SAME functions the browser
//! read-crypto (`dig-client-wasm`) and the on-chain/format crates share, so this
//! resolver can never skew from the canonical crypto. Nothing is reimplemented;
//! this module only sequences the gate-then-decrypt pipeline and maps failures to
//! the resolver's fail-closed [`ResolveError`] taxonomy.
//!
//! The node transport does NOT use this module: the node decrypts + verifies
//! server-side on the same machine and returns plaintext under a loopback trust
//! boundary. This is only for the blind rpc fetch (ciphertext + proof over the
//! public gateway), where the client MUST verify against the chain-anchored root
//! itself.

use crate::error::{ResolveError, Result};
use crate::urn::ParsedUrn;
use base64::Engine;
use digstore_core::codec::Decode;
use digstore_core::crypto::{decrypt_chunk, derive_decryption_key};
use digstore_core::{resource_leaf, Bytes32, MerkleProof, SecretSalt};

/// Parse a 32-byte secret salt from optional lowercase hex. `None`/empty ⇒ a
/// public store (the URN alone derives the key).
fn parse_salt(salt_hex: Option<&str>) -> Result<Option<[u8; 32]>> {
    match salt_hex {
        None => Ok(None),
        Some(s) if s.trim().is_empty() => Ok(None),
        Some(s) => {
            let b = Bytes32::from_hex(s.trim())
                .map_err(|_| ResolveError::Parse("secret salt must be 64 hex chars".into()))?;
            Ok(Some(b.0))
        }
    }
}

/// Decode a base64 merkle proof (the `inclusion_proof` field) into a
/// [`MerkleProof`]. The wire encoding is the Chia big-endian streamable codec.
fn decode_proof_b64(proof_b64: &str) -> Result<MerkleProof> {
    let raw = base64::engine::general_purpose::STANDARD
        .decode(proof_b64.trim().as_bytes())
        .map_err(|_| ResolveError::VerifyFailed("inclusion proof is not valid base64".into()))?;
    MerkleProof::from_bytes(&raw)
        .map_err(|_| ResolveError::VerifyFailed("inclusion proof encoding is invalid".into()))
}

/// The integrity gate (Digstore §9.3): the served `ciphertext` must be the proof's
/// leaf (`leaf = SHA-256(ciphertext)`), the path must fold to `proof.root`, and
/// `proof.root` must equal the chain-anchored `trusted_root`. Any failure is a hard
/// fail-closed [`ResolveError::VerifyFailed`] — a decoy / wrong-store / tampered
/// response can never chain to the real root.
pub fn verify_inclusion(ciphertext: &[u8], proof_b64: &str, trusted_root_hex: &str) -> Result<()> {
    let trusted_root = Bytes32::from_hex(trusted_root_hex.trim())
        .map_err(|_| ResolveError::VerifyFailed("trusted root must be 64 hex chars".into()))?;
    let proof = decode_proof_b64(proof_b64)?;

    if resource_leaf(ciphertext) != proof.leaf {
        return Err(ResolveError::VerifyFailed(
            "content does not match proof leaf (tampered ciphertext)".into(),
        ));
    }
    if !proof.verify() {
        return Err(ResolveError::VerifyFailed(
            "merkle path does not resolve to the declared root".into(),
        ));
    }
    if proof.root != trusted_root {
        return Err(ResolveError::VerifyFailed(
            "merkle root does not match the chain-anchored trusted root".into(),
        ));
    }
    Ok(())
}

/// The confidentiality half: derive the URN key, split the plain-concatenated chunk
/// ciphertexts by `chunk_lens` (per-chunk CIPHERTEXT byte lengths in order — no wire
/// length framing) and AES-256-GCM-SIV-open each in order. An empty/absent
/// `chunk_lens` is the common single-chunk resource. A tag failure fails closed with
/// [`ResolveError::DecryptFailed`].
pub fn decrypt(parsed: &ParsedUrn, ciphertext: &[u8], chunk_lens: &[u32]) -> Result<Vec<u8>> {
    let salt = parse_salt(parsed.salt.as_deref())?;
    let canonical = parsed.canonical_rootless().canonical();
    let aes_key = derive_decryption_key(&canonical, salt.map(SecretSalt).as_ref());

    // `chunk_lens` is gateway/node-supplied and is NOT covered by the merkle proof, so
    // it is UNTRUSTED. Accumulate + bound in u64 and slice against the REMAINING buffer
    // so a crafted length (e.g. `[len+2^31, 2^31]`) can never wrap `usize` on wasm32 and
    // slice out of bounds → `panic=abort` (wallet crash). Any inconsistency fails closed
    // as `DecryptFailed` (→ IntegrityFailure), never a panic.
    let ct_len = ciphertext.len() as u64;
    let plan: Vec<u64> = if chunk_lens.is_empty() {
        vec![ct_len]
    } else {
        chunk_lens.iter().map(|&l| l as u64).collect()
    };
    let mut total: u64 = 0;
    for &len in &plan {
        total = total.checked_add(len).ok_or(ResolveError::DecryptFailed)?;
    }
    if total != ct_len {
        return Err(ResolveError::DecryptFailed);
    }

    let mut plaintext = Vec::with_capacity(ciphertext.len());
    let mut p: usize = 0;
    for len in plan {
        // `total == ct_len` already bounds each `len`, but slice defensively anyway:
        // reject any window that would exceed the remaining buffer.
        let len = usize::try_from(len).map_err(|_| ResolveError::DecryptFailed)?;
        let end = p.checked_add(len).filter(|&e| e <= ciphertext.len());
        let end = end.ok_or(ResolveError::DecryptFailed)?;
        let ct = &ciphertext[p..end];
        p = end;
        let pt = decrypt_chunk(&aes_key, ct).map_err(|_| ResolveError::DecryptFailed)?;
        plaintext.extend_from_slice(&pt);
    }
    Ok(plaintext)
}

/// Verify then decrypt (gate-then-decrypt): the full rpc read-crypto pipeline.
pub fn verify_and_decrypt(
    parsed: &ParsedUrn,
    ciphertext: &[u8],
    proof_b64: &str,
    trusted_root_hex: &str,
    chunk_lens: &[u32],
) -> Result<Vec<u8>> {
    verify_inclusion(ciphertext, proof_b64, trusted_root_hex)?;
    decrypt(parsed, ciphertext, chunk_lens)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn urn() -> ParsedUrn {
        ParsedUrn::parse(&format!("urn:dig:chia:{}/a.bin", "ab".repeat(32))).unwrap()
    }

    #[test]
    fn decrypt_rejects_overflowing_chunk_lens_without_panic() {
        // `[len+2^31, 2^31]` wraps usize on wasm32; the u64-checked total must reject
        // it cleanly (fail-closed), never slice out of bounds / panic.
        let ct = vec![0u8; 10];
        let bad = [(1u32 << 31) + 10, 1u32 << 31];
        assert!(matches!(
            decrypt(&urn(), &ct, &bad),
            Err(ResolveError::DecryptFailed)
        ));
    }

    #[test]
    fn decrypt_rejects_chunk_total_mismatch() {
        let ct = vec![0u8; 10];
        assert!(matches!(
            decrypt(&urn(), &ct, &[999]),
            Err(ResolveError::DecryptFailed)
        ));
    }
}