diff --git a/Cargo.toml b/Cargo.toml index 8e78e53..7ef2a3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,11 +46,13 @@ sqlx = { version = "0.7", default-features = false, features = [ ] } chrono = { version = "0.4", features = ["serde"] } -# ECDH (secp256k1) + AES-256-GCM for the grin-wallet Owner API v3 secure channel, -# IF this service drives a grin-wallet as its scan/rewind engine instead of -# rewinding rangeproofs itself. Uncomment + pin when the scanner lands. -# secp256k1 = "0.28" -# aes-gcm = "0.10" +# View-only Grin rangeproof rewind — recognize a wallet's outputs from its +# `rewind_hash` alone (no spend key). `grin_secp256k1zkp` is Grin's OWN published +# crate (their fork of libsecp256k1-zkp) providing the C bulletproof rewind — we +# depend on it, we do not republish it. `blake2` computes the v3 rewind nonce. +# See src/rewind.rs. (C-FFI: the build host needs a C compiler.) +grin_secp256k1zkp = "0.7.15" +blake2 = "0.10" [dev-dependencies] tokio-test = "0.4" diff --git a/src/grin.rs b/src/grin.rs index f5f9d31..6a5cf58 100644 --- a/src/grin.rs +++ b/src/grin.rs @@ -171,16 +171,9 @@ async fn read_capped(resp: reqwest::Response, cap: usize) -> Result> { Ok(buf) } -/// Rewind a single output's rangeproof against an account's `rewind_hash`. On a -/// match, returns the recovered value + derivation path; `None` if the output -/// does not belong to the account. -/// -/// STUB (the core deferred milestone): this is the server-side analog of -/// `grin_recover_output`. It needs the grin crypto stack (secp256k1 + -/// bulletproofs) to compute the rewind nonce from `rewind_hash` + commitment and -/// rewind the proof. Uncomment the `secp256k1` / `aes-gcm`-adjacent crypto deps -/// in Cargo.toml and port the rewind + proof-message parse when the scanner -/// lands. +/// The recovered value + advisory derivation identifier for a matched output. +/// `key_id` is the authoritative 17-byte Grin Identifier (hex); `n_child` is +/// grin-canonical (see [`crate::rewind`]). #[allow(dead_code)] pub struct RewoundOutput { pub value: u64, @@ -188,9 +181,38 @@ pub struct RewoundOutput { pub n_child: u32, } +/// Rewind a single block output against an account's `rewind_hash` (VIEW-ONLY). +/// Returns the recovered value + advisory derivation path on a match, or `None` +/// if the output does not belong to the account (or the node data is malformed). +/// +/// `rewind_hash` is 64 hex chars (a 32-byte view credential); `out.commit` is a +/// 33-byte Pedersen commitment and `out.proof` the Bulletproof, both hex. Every +/// decode is fallible-to-`None`, so hostile/garbled node data can never panic the +/// scanner. The crypto (and its v3-only limitation) lives in [`crate::rewind`]. #[allow(dead_code)] -pub fn rewind_output(_rewind_hash: &str, _out: &ChainOutput) -> Option { - // TODO: compute nonce = f(rewind_hash, commit); rewind bulletproof; parse - // proof message -> (value, key_id, n_child). Returns None on non-match. - None +pub fn rewind_output(rewind_hash: &str, out: &ChainOutput) -> Option { + let rh: [u8; 32] = decode_hex_array(rewind_hash)?; + let commit: [u8; crate::rewind::COMMITMENT_LEN] = decode_hex_array(&out.commit)?; + let proof = hex::decode(&out.proof).ok()?; + match crate::rewind::rewind_output_view_only(&rh, &commit, &proof) { + Ok(Some(r)) => Some(RewoundOutput { + value: r.value, + key_id: r.key_id, + n_child: r.n_child, + }), + Ok(None) => None, + Err(e) => { + // Invalid nonce / oversized proof from a hostile or lagging node — + // skip this output for this account rather than failing the batch. + tracing::trace!(error = %e, "grin rewind skipped an output"); + None + } + } +} + +/// Decode a fixed-length hex string into `[u8; N]`; `None` on bad hex or a +/// length mismatch (never panics on hostile node data). +#[allow(dead_code)] +fn decode_hex_array(s: &str) -> Option<[u8; N]> { + hex::decode(s).ok()?.try_into().ok() } diff --git a/src/main.rs b/src/main.rs index c78a825..fa6634e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,7 @@ mod config; mod db; mod error; mod grin; +mod rewind; mod scanner; mod secret; diff --git a/src/rewind.rs b/src/rewind.rs new file mode 100644 index 0000000..f987e51 --- /dev/null +++ b/src/rewind.rs @@ -0,0 +1,277 @@ +//! View-only Grin rangeproof rewind — recognize a wallet's outputs from ONLY +//! its `rewind_hash` (a public view credential), recovering the value and the +//! derivation identifier (`key_id`) with NO spend/private key. +//! +//! This is the server-side analog of a wallet's seed-based `recover_output`, +//! but view-only. It computes the v3 rewind nonce (keyed BLAKE2b over the +//! commitment) and rewinds the Bulletproof exactly as grin-wallet's official +//! `scan_rewind_hash` does (grin/libwallet `internal/scan.rs`). Ownership is +//! decided by a successful rewind plus a well-formed 20-byte proof message. +//! +//! ## Scope + money-safety +//! - **v3 outputs only.** The legacy (pre-HF1) rewind nonce is derived from the +//! master secret, so legacy outputs are UNDISCOVERABLE from a `rewind_hash` +//! alone — the same limitation grin-wallet's view-only scan has. A wallet with +//! pre-HF1 outputs will under-report here. +//! - **Advisory paths.** The recovered `key_id` / `n_child` come from the +//! *unauthenticated* proof message; a view-only server cannot re-derive the +//! blinding factor to confirm ownership the way a seed holder can. They are +//! ADVISORY: a spending client MUST re-derive the commitment from its seed +//! before using an output as an input. **`key_id` (the full 17-byte +//! Identifier) is authoritative**; `n_child` is grin-canonical (== 0 for a +//! Smirk `[0,0,n,0]` path — the real index lives inside `key_id`). +//! - Because we cannot run the seed-side commitment check, we apply strict +//! message sanity checks (below) to limit view-only false positives; the +//! bulletproof rewind itself carries a checksum, so an accidental cross-wallet +//! match is cryptographically negligible. +//! +//! The nonce + rewind + parse logic is a faithful, minimal copy of the reusable, +//! cross-validated crypto in smirk's `grin-ext` crate (`recovery.rs`), pinned by +//! the golden-vector tests below (generated by grin's OWN reference +//! `ProofBuilder`). It depends only on the upstream published `grin_secp256k1zkp` +//! and never touches a private key. + +use blake2::digest::{consts::U32, FixedOutput, Update}; +use blake2::Blake2bMac; + +/// Grin Pedersen commitment length (compressed): 33 bytes. +pub const COMMITMENT_LEN: usize = 33; +/// Grin output `Identifier` length: depth(1) || 4×u32(16) = 17 bytes. +pub const IDENTIFIER_LEN: usize = 17; + +/// A view-only-recovered output: value + advisory derivation identifier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recovered { + /// Committed value, in nanogrin. + pub value: u64, + /// The 17-byte Grin `Identifier`, hex-encoded (34 chars). AUTHORITATIVE — a + /// spending client re-derives its key from this. `[0]` = depth, `[1..]` = + /// 4×u32 big-endian path. + pub key_id: String, + /// grin-canonical `n_child` = `path[depth-1]`. NOTE: this is `0` for a Smirk + /// `[0,0,n,0]` path; treat `key_id` as the source of truth for spending. + pub n_child: u32, +} + +/// v3 per-output rewind nonce: `blake2b-256(data = rewind_hash, key = commitment)`. +fn output_rewind_nonce( + rewind_hash: &[u8; 32], + commitment: &[u8; COMMITMENT_LEN], +) -> Result<[u8; 32], String> { + keyed_blake2b_256(commitment, rewind_hash) +} + +/// Native keyed BLAKE2b-256 (`blake2b(32, key, data)`; the key goes through +/// BLAKE2b's parameter block, NOT HMAC). Mirrors +/// `grin-ext::recovery::keyed_blake2b_256`. +fn keyed_blake2b_256(key: &[u8], data: &[u8]) -> Result<[u8; 32], String> { + let mut mac = >::new_with_salt_and_personal(key, &[], &[]) + .map_err(|e| format!("blake2b keyed init: {e:?}"))?; + Update::update(&mut mac, data); + let out = mac.finalize_fixed(); + let mut res = [0u8; 32]; + res.copy_from_slice(&out); + Ok(res) +} + +/// Rewind a Bulletproof with `rewind_nonce`. On success returns the recovered +/// value + the embedded 20-byte proof message. `Ok(None)` = the nonce does not +/// match this proof (not this account's output). Mirrors +/// `grin-ext::recovery::bullet_proof_rewind_with_message`, keeping only the +/// value + message (a view-only server never needs the rewind blind). +fn bullet_proof_rewind_with_message( + commitment: &[u8; COMMITMENT_LEN], + rewind_nonce: &[u8; 32], + proof_bytes: &[u8], +) -> Result)>, String> { + use secp256k1zkp::pedersen::{Commitment, RangeProof}; + use secp256k1zkp::{ContextFlag, Secp256k1, SecretKey}; + + let secp = Secp256k1::with_caps(ContextFlag::Commit); + let commit = Commitment::from_vec(commitment.to_vec()); + let nonce = SecretKey::from_slice(&secp, rewind_nonce) + .map_err(|e| format!("invalid rewind nonce: {e:?}"))?; + + if proof_bytes.len() > secp256k1zkp::constants::MAX_PROOF_SIZE { + return Err(format!("proof too large: {} bytes", proof_bytes.len())); + } + let mut proof = RangeProof { + proof: [0u8; secp256k1zkp::constants::MAX_PROOF_SIZE], + plen: proof_bytes.len(), + }; + proof.proof[..proof_bytes.len()].copy_from_slice(proof_bytes); + + match secp.rewind_bullet_proof(commit, nonce, None, proof) { + // `info.message` is the fixed 20-byte proof-message buffer; `info.mlen` + // is unreliable (the binding reports 0), so we always take 20 bytes. + Ok(info) => Ok(Some((info.value, info.message.as_bytes().to_vec()))), + Err(_) => Ok(None), + } +} + +/// View-only recover: v3 nonce → rewind → parse the 20-byte message into the +/// advisory derivation identifier. There is NO commitment re-derivation (that +/// needs the seed). Returns `Ok(None)` for a non-match or an implausible message. +pub fn rewind_output_view_only( + rewind_hash: &[u8; 32], + commitment: &[u8; COMMITMENT_LEN], + proof: &[u8], +) -> Result, String> { + let nonce = output_rewind_nonce(rewind_hash, commitment)?; + let Some((value, message)) = bullet_proof_rewind_with_message(commitment, &nonce, proof)? else { + return Ok(None); + }; + Ok(parse_v3_message(value, &message)) +} + +/// Parse a rewound 20-byte v3 proof message into an advisory [`Recovered`]. +/// Pure bytes; mirrors the v3 branch of `grin-ext::recovery::{check_output, +/// try_recover}` MINUS the private-key commitment re-derivation. Rejects +/// implausible messages (`None`) to limit view-only false positives, since the +/// seed-side ownership check is unavailable here. +fn parse_v3_message(value: u64, message: &[u8]) -> Option { + // The Grin proof message is exactly 20 bytes. + if message.len() != 20 { + return None; + } + // v3 marker: reserved byte + standard wallet-type byte are both 0. + if message[0] != 0 || message[1] != 0 { + return None; + } + // Switch commitment type: 0 = None, 1 = Regular. Reject anything else. + if message[2] > 1 { + return None; + } + // Depth: grin's Identifier holds at most 4 path elements; depth 0 is not a + // real output. (grin clamps to <=4; we reject out-of-range to compensate for + // the missing seed-side check.) + let depth = message[3]; + if depth == 0 || depth > 4 { + return None; + } + // A zero-value output is not a real spendable output. + if value == 0 { + return None; + } + // Identifier: [0] = depth, [1..17] = the 16 path bytes verbatim. + let mut identifier = [0u8; IDENTIFIER_LEN]; + identifier[0] = depth; + identifier[1..IDENTIFIER_LEN].copy_from_slice(&message[4..20]); + // grin-canonical n_child = path[depth-1] (4 big-endian u32s). + let mut full_path = [0u32; 4]; + for (i, chunk) in message[4..20].chunks_exact(4).enumerate() { + full_path[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + let n_child = full_path[(depth as usize) - 1]; + Some(Recovered { + value, + key_id: hex::encode(identifier), + n_child, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Helper: decode a hex vector field into a fixed/var buffer. + fn h(s: &str) -> Vec { + hex::decode(s).expect("hex") + } + fn arr32(s: &str) -> [u8; 32] { + h(s).try_into().expect("32 bytes") + } + fn arr33(s: &str) -> [u8; 33] { + h(s).try_into().expect("33 bytes") + } + + // ── GOLDEN VECTORS ────────────────────────────────────────────────────── + // Generated by grin's OWN reference `ProofBuilder` (grin_core::libtx::proof) + // over a fixed seed, then the view credential (`rewind_hash`) extracted via + // grin-ext. If the vendored nonce/rewind/parse ever drifts from grin's + // canonical crypto — or the upstream `grin_secp256k1zkp` differs from the + // monorepo's vendored fork — these fail. See recovery.rs cross-validation. + const RH: &str = "c2f675f2b05b5bed7d7f01480c6f2aaf368fe4da5e169c3b6d18bb354ba784a2"; + + // depth-3 (Grim): path [0,0,7,0] → n_child (canonical) = 7. + const V1_COMMIT: &str = "099c6701a2c0cf2c02ada5a051a87e554524bcfa7d77339caf35b607fc1a1b2226"; + const V1_PROOF: &str = "69d7d34b41a9cff366101cd1473fd28dde406de95fa29d403a30684f93a6c857409eb08742b6fd8704c73ea65e982f3f332dbb6f2c61458b6acb901b8d98be9b01cf44cbcf9a8e087638ceabd7666c2bc9728d46ce21a5560da9710e69d4ae6099ad49666851f4181b2fea9f10ff400a5fc789ef132b96668bedcc8697349798747fc68dcb32c081f39810d7d05846f955cf398abf358ec4c013c6cf35e7abb4552a1bf8a2eaff0a2c4bd5a3f232fd323f1cd59588623dfdbbd3276e1e4dbc5c00038cb18ba9ee83d70a803bcbb5f32972153454a093bbee48b14c2653ed34c5bc94b0220d10f3b97f4914d62c0288e9146ad34527634736ff28db7dc6171cee1bed2bbe3c85788417778556ceb259fe8fd7571ff884b6dacc375d56d7a528cc037993c0402c6f7b2ca13940501e798b6476ae24c4b65cca0805e98de96819b246997de4f3680bf81b3c7dbf4c456c29677e3a3be281993e06b8c8aae14559672f5103b80f5aaba187ea0c74903af9896433aec723063b695efd2254b831143c7335058496aa4a8d8a90c0bfb4260da8790f32073f4f809a59f93ef33c0379d20a48212c1ac36181eeef59ababfb1b6f9a0ce4c9baaa83299c21ccad09a0493ad79f35083b00a149d72745feb8e6776fb16f06128a37f1ffd406476deeb5e7f35208b3cc7544197be7def4509fdcbd92d8bd72e3fd0437b3f249c247981ba70bc2ebf985c3efa546ceaf953e7061b33b4f1a5da78a32406b6258483efb1c64e49b77e3ca960e1f9f1be3779d657fd879ad8d282a3bf0a19d2c6f3a0b161e4fb0e2c2dff2efbb7fbf8be5b355b22f0fe2a2166ebe0627b9006cb740a190a50dcde3c068734f9554efaa9a196c7a985239ddce0b246c34c8627297fa3b3050cac82d7af9ab7063e8873f000f533f9a250cdc777c4980cf569f6c4011fcb3dfb5b1eb995c"; + const V1_VALUE: u64 = 12_345_678_900; + const V1_KEY_ID: &str = "0300000000000000000000000700000000"; + const V1_NCHILD: u32 = 7; + + // depth-4 (Smirk): path [0,0,42,0] → n_child (canonical) = 0 (R4). + const V2_COMMIT: &str = "09b257873a372d1043b53a42dd5ebf8e85ffeb2a789ce7acd3ad7b3b2e182f67a3"; + const V2_PROOF: &str = "b9c04769080d9252c64d3d5869d24e498d412fd14d772c74bff76412d62373ca571e7f839154cdc9e0e2624b7fd64aab470fe2ce6eba9dc83d66ad8216349ee803c84d3be9a1a9c5444a53d29c8bc51314d614174a2652025a46843568278e1b95e27b93bcddcec75a20829906e3a43d61765f216981bab3a2190c660512cd640234d222029fbddc73a024d450238f5e732d2bf70f0a21657d09696b7ee578ae2cfbbaf7422123300e2bb00b8aeeaa07892d85623381df81380061da06119a3755bf87c84cd38c3999d7a835ff5d8f453f05f53093451038ce1e022b2a52bd3f084ac26f9857ddb94d23d67f18186b8df22393b0fc4d6926d69b6520ce891309c222db7f8ae50699b1f76ca5666f05535d18f84ac19d890337f50685c4fcf39ce3d64f3b9f09e09ddc41f1b607973d97e96c4b42cfa55bf944404237d34e4e7c01ca0521dfd803b57d210d3209f965b0a8020856b0bb86632d76dad8359a77e8eb3803d750cf69fd66626361626f75e7a5ca46f941410ebcb15e8606f48c55e40fb586dafe41ba323859547838cc237844a2bb598bc88e515b1be64b5d17f1835abb7ec589bc6678008a14cdbce689ae0932655e2dcd1e69f210f596dc1a621894b15373c71097e413fd87d52064acff2f876ee5fc2442322a13b7502b43e9669f96231c5bcce22506803cb67d2d852907cddeb49b7b01a2352803733ea88de83717fd8d4675b6ae728cae5d3d893aab2d6afdd902f4b1ce53bb3181dd98b62daa2977987402c2810c23fa85c07a2a931459a2365913235fd3e6e26cd5ab787575188fc13671e764cb8f0927330439d2dc45dd47019a06e848e520ab69832c4fe0bb758a0b6e123d85510762f45d6c3d2bf5b2cb2e33d2ac3d04aae257006b7d8f02f44709444ecfb845b367dde0f393e382d70d340287ada37110f2de5b6c20ecfcb1"; + const V2_VALUE: u64 = 5_000_000_000; + const V2_KEY_ID: &str = "0400000000000000000000002a00000000"; + const V2_NCHILD: u32 = 0; + + #[test] + fn golden_v1_depth3_grim() { + let got = rewind_output_view_only(&arr32(RH), &arr33(V1_COMMIT), &h(V1_PROOF)) + .expect("rewind errored") + .expect("v1 must be recovered by its own rewind_hash"); + assert_eq!(got.value, V1_VALUE, "value"); + assert_eq!(got.key_id, V1_KEY_ID, "key_id (authoritative)"); + assert_eq!(got.n_child, V1_NCHILD, "n_child canonical"); + } + + #[test] + fn golden_v2_depth4_smirk_nchild_zero() { + let got = rewind_output_view_only(&arr32(RH), &arr33(V2_COMMIT), &h(V2_PROOF)) + .expect("rewind errored") + .expect("v2 must be recovered by its own rewind_hash"); + assert_eq!(got.value, V2_VALUE, "value"); + assert_eq!(got.key_id, V2_KEY_ID, "key_id (authoritative)"); + // R4: the spendable index (42) lives in key_id, NOT in n_child. + assert_eq!(got.n_child, V2_NCHILD, "n_child is grin-canonical 0 for [0,0,n,0]"); + assert!(got.key_id.contains("2a"), "spendable index 42=0x2a must be in key_id"); + } + + #[test] + fn wrong_rewind_hash_yields_none() { + // Flip one byte of the correct rewind_hash → different nonce → the + // bulletproof rewind fails → not our output. + let mut rh = arr32(RH); + rh[0] ^= 0x01; + // A non-matching rewind_hash must not recover the output — whether the + // nonce simply doesn't match (Ok(None)) or the flipped bytes aren't a + // valid secp scalar (Err). Either way: not recovered. + let got = rewind_output_view_only(&rh, &arr33(V1_COMMIT), &h(V1_PROOF)); + assert!( + !matches!(got, Ok(Some(_))), + "a non-matching rewind_hash must not recover the output: {got:?}", + ); + } + + #[test] + fn same_wallet_recovers_both_outputs() { + // Both vectors are outputs of the SAME wallet, so the one rewind_hash + // must recover both (and only value/key_id differ). + let a = rewind_output_view_only(&arr32(RH), &arr33(V1_COMMIT), &h(V1_PROOF)).unwrap(); + let b = rewind_output_view_only(&arr32(RH), &arr33(V2_COMMIT), &h(V2_PROOF)).unwrap(); + assert!(a.is_some() && b.is_some()); + assert_ne!(a.unwrap().key_id, b.unwrap().key_id); + } + + #[test] + fn parse_rejects_malformed_messages() { + // 20-byte messages that must be rejected view-only. + assert!(parse_v3_message(100, &[0u8; 19]).is_none(), "wrong length"); + let mut m = [0u8; 20]; + m[3] = 4; // depth ok + m[2] = 2; // invalid switch + assert!(parse_v3_message(100, &m).is_none(), "invalid switch"); + let mut m = [0u8; 20]; + m[3] = 0; // depth 0 + assert!(parse_v3_message(100, &m).is_none(), "depth 0"); + let mut m = [0u8; 20]; + m[0] = 1; // v3 marker broken + m[3] = 4; + assert!(parse_v3_message(100, &m).is_none(), "bad v3 marker"); + let mut m = [0u8; 20]; + m[3] = 4; + assert!(parse_v3_message(0, &m).is_none(), "zero value"); + } +}