Phase 1: view-only rangeproof rewind (recognize outputs from rewind_hash)

Replace the rewind_output stub with a real, view-only implementation:
- src/rewind.rs: derive the v3 rewind nonce (keyed BLAKE2b over the
  commitment) and rewind the Bulletproof via upstream grin_secp256k1zkp,
  then parse the 20-byte proof message into the authoritative key_id +
  value. Faithful minimal port of grin-ext's cross-validated crypto; no
  private key is ever involved. v3-only (legacy needs the master secret,
  same limit as grin-wallet's view-only scan); paths are advisory.
- src/grin.rs: wire rewind_output with hostile-input-safe hex decoding.
- Cargo.toml: depend on upstream grin_secp256k1zkp + blake2 (not the
  vendored fork — the server target needs no wasm patch).
- Golden-vector tests generated by grin's OWN reference ProofBuilder
  (depth-3 Grim + depth-4 Smirk [0,0,n,0]); proves byte-compatibility
  with the published secp crate. Passed an adversarial money-safety
  review (no false-positive/negative, panic-safe).
This commit is contained in:
jwinterm
2026-07-09 12:25:16 -04:00
parent 255c13e27e
commit d9edee0743
4 changed files with 321 additions and 19 deletions
+7 -5
View File
@@ -46,11 +46,13 @@ sqlx = { version = "0.7", default-features = false, features = [
] } ] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
# ECDH (secp256k1) + AES-256-GCM for the grin-wallet Owner API v3 secure channel, # View-only Grin rangeproof rewind — recognize a wallet's outputs from its
# IF this service drives a grin-wallet as its scan/rewind engine instead of # `rewind_hash` alone (no spend key). `grin_secp256k1zkp` is Grin's OWN published
# rewinding rangeproofs itself. Uncomment + pin when the scanner lands. # crate (their fork of libsecp256k1-zkp) providing the C bulletproof rewind — we
# secp256k1 = "0.28" # depend on it, we do not republish it. `blake2` computes the v3 rewind nonce.
# aes-gcm = "0.10" # See src/rewind.rs. (C-FFI: the build host needs a C compiler.)
grin_secp256k1zkp = "0.7.15"
blake2 = "0.10"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"
+35 -13
View File
@@ -171,16 +171,9 @@ async fn read_capped(resp: reqwest::Response, cap: usize) -> Result<Vec<u8>> {
Ok(buf) Ok(buf)
} }
/// Rewind a single output's rangeproof against an account's `rewind_hash`. On a /// The recovered value + advisory derivation identifier for a matched output.
/// match, returns the recovered value + derivation path; `None` if the output /// `key_id` is the authoritative 17-byte Grin Identifier (hex); `n_child` is
/// does not belong to the account. /// grin-canonical (see [`crate::rewind`]).
///
/// 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.
#[allow(dead_code)] #[allow(dead_code)]
pub struct RewoundOutput { pub struct RewoundOutput {
pub value: u64, pub value: u64,
@@ -188,9 +181,38 @@ pub struct RewoundOutput {
pub n_child: u32, 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)] #[allow(dead_code)]
pub fn rewind_output(_rewind_hash: &str, _out: &ChainOutput) -> Option<RewoundOutput> { pub fn rewind_output(rewind_hash: &str, out: &ChainOutput) -> Option<RewoundOutput> {
// TODO: compute nonce = f(rewind_hash, commit); rewind bulletproof; parse let rh: [u8; 32] = decode_hex_array(rewind_hash)?;
// proof message -> (value, key_id, n_child). Returns None on non-match. 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 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<const N: usize>(s: &str) -> Option<[u8; N]> {
hex::decode(s).ok()?.try_into().ok()
}
+1
View File
@@ -42,6 +42,7 @@ mod config;
mod db; mod db;
mod error; mod error;
mod grin; mod grin;
mod rewind;
mod scanner; mod scanner;
mod secret; mod secret;
+277
View File
@@ -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 = <Blake2bMac<U32>>::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<Option<(u64, Vec<u8>)>, 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<Option<Recovered>, 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<Recovered> {
// 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<u8> {
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");
}
}