From e2beb17dda61c4e734194ccb05b2ed97220bc50b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 23 Jun 2026 09:08:39 -0400 Subject: [PATCH] feat: grin view-only client (non-custodial, hardened secure session) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of grin support, reworked clean from the chain-audit playbook. The open backend is NON-CUSTODIAL and holds no Grin spend key, so this is a VIEW-ONLY client — NOT a port of the reference's spend-capable Owner-API driver. Rationale + the grin-lws plan: docs/private/GRIN_LWS_DESIGN.md. Model: Grin has had view/spend separation since 2021 (grin_keychain::ViewKey; rewind_hash = blake2b of the PUBLIC root key). grin-wallet's Owner API scan_rewind_hash(rewind_hash, start_height) does the chain scan and returns a ViewWallet{output_result, total_balance, last_pmmr_index} (incremental). This client drives that; the wallet builds + signs txs locally and hands the backend only the finalized tx to broadcast. Surface (view-only): open_wallet (operator scan-engine token, cached), scan_rewind_hash -> ViewWallet, node get_status/get_height, broadcast (node Foreign push_transaction). DROPPED: all spend methods (init_send_tx, finalize, balance, cancel) and the node rangeproof-scan paths (scan_rewind_hash does the scanning). Hardened Owner-API v3 secure transport (ECDH secp256k1 + AES-256-GCM): - session init is TOCTOU-free (re-checked under the write lock); - AES-GCM nonce counter advances with checked_add and is never reused (overflow -> error, not wrap); - the ECDH shared key, wallet password, and response bodies are NEVER logged or interpolated into errors (errors carry a static label + numeric code); - tokio (non-poisoning) RwLock for the token + session; - typed Ok/Err envelopes deserialized directly (no Value clone of ViewWallet); - bounded streaming response read (16 MiB) + request/connect timeouts; - secrets wrapped in core::secret::Secret. 8 L2 tests: AES-GCM encrypt/decrypt round-trip, nonce monotonicity + byte layout, nonce-exhaustion-errors-not-panics, wrong-key auth rejection, bad nonce/ciphertext rejection, Ok/Err/rpc-error envelope mapping (message not surfaced), construction without I/O, confirmation math (tip/zero/future). --- src/infra/grin/api.rs | 152 +++++++++++++++ src/infra/grin/mod.rs | 136 +++++++++++++ src/infra/grin/secure.rs | 410 +++++++++++++++++++++++++++++++++++++++ src/infra/mod.rs | 1 + 4 files changed, 699 insertions(+) create mode 100644 src/infra/grin/api.rs create mode 100644 src/infra/grin/mod.rs create mode 100644 src/infra/grin/secure.rs diff --git a/src/infra/grin/api.rs b/src/infra/grin/api.rs new file mode 100644 index 0000000..c029105 --- /dev/null +++ b/src/infra/grin/api.rs @@ -0,0 +1,152 @@ +//! Grin view-only API surface: open the operator's scan-engine wallet, run a +//! `rewind_hash` scan, read node tip/status, and broadcast a finalized tx. +//! +//! No spend method exists here by design — the backend holds no spend key; the +//! wallet builds and signs transactions locally and hands the backend only the +//! finalized transaction to broadcast. + +use tracing::instrument; + +use super::{GrinClient, GrinStatus, ViewWallet}; +use crate::error::AppError; + +impl GrinClient { + /// Open the operator's scan-engine wallet and cache its session token. + /// TOCTOU-free: re-checks the cache under the write lock. The wallet is used + /// only as a scan engine — its own keys are irrelevant to view-only scans. + #[instrument(skip(self))] + pub async fn open_wallet(&self) -> Result { + if let Some(token) = self.session_token.read().await.as_ref() { + return Ok(token.clone()); + } + let mut guard = self.session_token.write().await; + if let Some(token) = guard.as_ref() { + return Ok(token.clone()); + } + // open_wallet params: (name: Option, password). + let token: String = self + .owner_rpc( + "open_wallet", + (Option::::None, self.wallet_password.expose()), + ) + .await?; + *guard = Some(token.clone()); + Ok(token) + } + + /// View-only scan for the outputs a `rewind_hash` recognizes, from + /// `start_height` (a wallet birthday or the last scanned height). Returns the + /// outputs, total balance, and `last_pmmr_index` for the next incremental scan. + /// Cannot spend — `rewind_hash` is derived from a public key. + #[instrument(skip(self, rewind_hash))] + pub async fn scan_rewind_hash( + &self, + rewind_hash: &str, + start_height: Option, + ) -> Result { + match self.scan_attempt(rewind_hash, start_height).await { + Ok(view) => Ok(view), + Err(_) => { + // The cached wallet token may be stale (e.g. grin-wallet was + // restarted: the owner_rpc -32001 path re-handshakes the secure + // channel but the old token no longer opens the wallet). Drop it + // and retry once with a fresh open_wallet, so a wallet restart + // self-heals instead of wedging scans until our own restart. + *self.session_token.write().await = None; + self.scan_attempt(rewind_hash, start_height).await + } + } + } + + async fn scan_attempt( + &self, + rewind_hash: &str, + start_height: Option, + ) -> Result { + let token = self.open_wallet().await?; + // scan_rewind_hash params: (token, rewind_hash, start_height). + self.owner_rpc("scan_rewind_hash", (token, rewind_hash, start_height)) + .await + } + + /// Node status (chain tip height + sync status). + #[instrument(skip(self))] + pub async fn get_status(&self) -> Result { + self.node_rpc( + &self.node_api_url, + Some((self.node_api_user.as_str(), self.node_api_pass.expose())), + "get_status", + Vec::<()>::new(), + ) + .await + } + + /// Current chain tip height. + pub async fn get_height(&self) -> Result { + Ok(self.get_status().await?.tip.height) + } + + /// Broadcast a finalized transaction via the node Foreign API + /// (`push_transaction`). The wallet builds + signs locally; the backend only + /// relays. `tx` is the finalized transaction object. + #[instrument(skip(self, tx))] + pub async fn broadcast(&self, tx: &serde_json::Value) -> Result<(), AppError> { + let auth = if self.node_foreign_api_secret.is_empty() { + None + } else { + Some(("grin", self.node_foreign_api_secret.expose())) + }; + // push_transaction params: (tx, fluff). + let _: serde_json::Value = self + .node_rpc( + &self.node_foreign_api_url, + auth, + "push_transaction", + (tx, false), + ) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::GrinConfig; + use crate::infra::grin::ViewWalletOutputResult; + + fn cfg() -> GrinConfig { + GrinConfig { + owner_api_url: "http://127.0.0.1:3420/v3/owner".into(), + owner_api_secret: "owner-secret".into(), + wallet_password: "wallet-pass".into(), + foreign_api_url: String::new(), + node_api_url: "http://127.0.0.1:3413/v2/owner".into(), + node_api_user: "grin".into(), + node_api_pass: "node-pass".into(), + node_foreign_api_url: "http://127.0.0.1:3413/v2/foreign".into(), + node_foreign_api_secret: String::new(), + } + } + + #[test] + fn new_builds_without_io() { + assert!(GrinClient::new(&cfg()).is_ok()); + } + + #[test] + fn confirmations_handles_tip_zero_and_future() { + let out = |height| ViewWalletOutputResult { + commit: "c".into(), + value: 1, + height, + mmr_index: 0, + is_coinbase: false, + lock_height: 0, + }; + assert_eq!(out(100).confirmations(110), 11); // 110 - 100 + 1 + assert_eq!(out(110).confirmations(110), 1); // tip block + assert_eq!(out(0).confirmations(110), 0); // unconfirmed / no height + assert_eq!(out(120).confirmations(110), 0); // beyond tip -> not a bogus count + } +} diff --git a/src/infra/grin/mod.rs b/src/infra/grin/mod.rs new file mode 100644 index 0000000..abb22e4 --- /dev/null +++ b/src/infra/grin/mod.rs @@ -0,0 +1,136 @@ +//! Grin chain access — **view-only**, for a non-custodial backend. +//! +//! The backend holds no Grin spend key. Grin has had view/spend separation since +//! 2021 (`grin_keychain::ViewKey`): a wallet exports a `rewind_hash` (derived from +//! its *public* root key) that lets a holder recognize the wallet's outputs and +//! read their amounts, but **not** spend them. grin-wallet's Owner API +//! `scan_rewind_hash(rewind_hash, start_height)` does the chain scan (rangeproof +//! rewind) and returns a [`ViewWallet`]; this client drives that, plus node +//! status and broadcast. Spending (input selection, kernel signing) happens in +//! the wallet, locally — never here. See `docs/private/GRIN_LWS_DESIGN.md`. +//! +//! The Owner API v3 transport is an ECDH (secp256k1) handshake + AES-256-GCM +//! channel; the secure session is initialized once (TOCTOU-free), the nonce +//! counter advances with `checked_add` (never reused), and no key / password / +//! response body is ever logged. Every response read is size-bounded. + +mod api; +mod secure; + +use std::time::Duration; + +use serde::Deserialize; +use tokio::sync::RwLock; + +use crate::config::GrinConfig; +use crate::core::secret::Secret; +use crate::error::AppError; + +/// Per-request / connect deadlines (grin-wallet or node may be down). +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Hard cap on any grin response body (enforced while streaming). A `ViewWallet` +/// for a busy wallet is the largest legitimate payload and sits well under this. +const MAX_GRIN_BODY_BYTES: usize = 16 * 1024 * 1024; + +/// Secure-session state for the Owner API v3 encrypted channel. +struct SecureSession { + /// AES-256 key = ECDH shared secret (raw x-coordinate, per Grin). + shared_key: [u8; 32], + /// Monotonic AES-GCM nonce counter; advanced with `checked_add`. + nonce_counter: u64, +} + +/// View-only Grin client. Cheap to clone is NOT supported (holds session locks); +/// construct once and share via `Arc`. +pub struct GrinClient { + // Wallet Owner API (v3 encrypted) — for the view-only scan. + owner_api_url: String, + owner_api_secret: Secret, + wallet_password: Secret, + // Node APIs — status (Owner) + broadcast (Foreign). + node_api_url: String, + node_api_user: String, + node_api_pass: Secret, + node_foreign_api_url: String, + node_foreign_api_secret: Secret, + http: reqwest::Client, + /// Cached `open_wallet` token (non-poisoning async lock). + session_token: RwLock>, + /// ECDH/AES-GCM secure session (non-poisoning async lock). + secure_session: RwLock>, +} + +impl GrinClient { + /// Build a view-only Grin client from config. No network I/O until a call. + pub fn new(cfg: &GrinConfig) -> Result { + let http = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|_| AppError::NodeError("failed to build Grin HTTP client".into()))?; + Ok(Self { + owner_api_url: cfg.owner_api_url.clone(), + owner_api_secret: Secret::new(cfg.owner_api_secret.clone()), + wallet_password: Secret::new(cfg.wallet_password.clone()), + node_api_url: cfg.node_api_url.clone(), + node_api_user: cfg.node_api_user.clone(), + node_api_pass: Secret::new(cfg.node_api_pass.clone()), + node_foreign_api_url: cfg.node_foreign_api_url.clone(), + node_foreign_api_secret: Secret::new(cfg.node_foreign_api_secret.clone()), + http, + session_token: RwLock::new(None), + secure_session: RwLock::new(None), + }) + } +} + +// ── public result types (mirror grin-wallet) ──────────────────────────────── + +/// Result of a view-only scan (`scan_rewind_hash`): the outputs a `rewind_hash` +/// recognizes, their total, and the resume index for the next incremental scan. +#[derive(Debug, Clone, Deserialize)] +pub struct ViewWallet { + pub rewind_hash: String, + #[serde(default)] + pub output_result: Vec, + pub total_balance: u64, + pub last_pmmr_index: u64, +} + +/// A single output recovered by a view-only scan. Amounts are nanogrin (`u64`). +#[derive(Debug, Clone, Deserialize)] +pub struct ViewWalletOutputResult { + pub commit: String, + pub value: u64, + pub height: u64, + pub mmr_index: u64, + #[serde(default)] + pub is_coinbase: bool, + #[serde(default)] + pub lock_height: u64, +} + +impl ViewWalletOutputResult { + /// Confirmations at `tip_height`; `0` if the output claims a block beyond the + /// tip (a lagging/inconsistent node) rather than a bogus count. + pub fn confirmations(&self, tip_height: u64) -> u64 { + if self.height == 0 || self.height > tip_height { + return 0; + } + tip_height.saturating_sub(self.height).saturating_add(1) + } +} + +/// Node status (subset of grin node `get_status`). +#[derive(Debug, Clone, Deserialize)] +pub struct GrinStatus { + pub tip: GrinTip, + #[serde(default)] + pub sync_status: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GrinTip { + pub height: u64, +} diff --git a/src/infra/grin/secure.rs b/src/infra/grin/secure.rs new file mode 100644 index 0000000..29d3d44 --- /dev/null +++ b/src/infra/grin/secure.rs @@ -0,0 +1,410 @@ +//! Grin Owner API v3 secure transport: ECDH (secp256k1) handshake + AES-256-GCM, +//! plus the plain node JSON-RPC path. All request/response framing lives here. +//! +//! Hardening vs. the reference: session init is TOCTOU-free (re-checked under the +//! write lock); the AES-GCM nonce counter advances with `checked_add` and is +//! never reused; the ECDH shared key, the wallet password, and response bodies +//! are never logged or interpolated into errors; every response read is bounded. + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use base64::Engine; +use futures::StreamExt; +use k256::{ecdh::EphemeralSecret, elliptic_curve::sec1::ToEncodedPoint, PublicKey}; +use rand::rngs::OsRng; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::json; + +use super::{GrinClient, SecureSession, MAX_GRIN_BODY_BYTES}; +use crate::error::AppError; + +/// JSON-RPC request frame. +#[derive(Serialize)] +struct JsonRpcRequest<'a, T> { + jsonrpc: &'static str, + id: u32, + method: &'a str, + params: T, +} + +impl<'a, T> JsonRpcRequest<'a, T> { + fn new(method: &'a str, params: T) -> Self { + Self { + jsonrpc: "2.0", + id: 1, + method, + params, + } + } +} + +/// Standard JSON-RPC envelope wrapping Grin's `{"Ok": _}` / `{"Err": _}` result. +#[derive(Deserialize)] +struct RpcEnvelope { + result: Option>, + error: Option, +} + +/// Grin's externally-tagged result. Deserializing `R` directly (no +/// `serde_json::Value` intermediate) avoids cloning a large `ViewWallet`. +#[derive(Deserialize)] +enum GrinOk { + Ok(R), + #[allow(dead_code)] + Err(serde_json::Value), +} + +/// Only the numeric code is read; the (untrusted) message is intentionally not. +#[derive(Deserialize)] +struct JsonRpcError { + code: i64, +} + +/// The `{nonce, body_enc}` payload of an `encrypted_request_v3` exchange. +#[derive(Deserialize)] +struct EncryptedParts { + nonce: String, + body_enc: String, +} + +impl GrinClient { + /// Ensure the ECDH/AES-GCM session exists. TOCTOU-free: the existence check + /// is repeated under the write lock, so concurrent first-callers perform at + /// most one handshake. + async fn ensure_session(&self) -> Result<(), AppError> { + if self.secure_session.read().await.is_some() { + return Ok(()); + } + let mut guard = self.secure_session.write().await; + if guard.is_some() { + return Ok(()); + } + + // Our ephemeral secp256k1 keypair; Grin expects a COMPRESSED pubkey hex. + let client_secret = EphemeralSecret::random(&mut OsRng); + let client_pubkey_hex = + hex::encode(client_secret.public_key().to_encoded_point(true).as_bytes()); + + let body = JsonRpcRequest::new( + "init_secure_api", + json!({ "ecdh_pubkey": client_pubkey_hex }), + ); + let bytes = self + .post_capped( + &self.owner_api_url, + Some(("grin", self.owner_api_secret.expose())), + &body, + ) + .await?; + + // init_secure_api result is `{"Ok": ""}`. + let env: RpcEnvelope = serde_json::from_slice(&bytes) + .map_err(|_| AppError::NodeError("grin init_secure_api: invalid response".into()))?; + if let Some(e) = env.error { + return Err(AppError::NodeError(format!( + "grin init_secure_api error {}", + e.code + ))); + } + let server_pubkey_hex = match env.result { + Some(GrinOk::Ok(s)) => s, + _ => { + return Err(AppError::NodeError( + "grin init_secure_api: no result".into(), + )) + } + }; + + let server_pubkey = PublicKey::from_sec1_bytes( + &hex::decode(&server_pubkey_hex) + .map_err(|_| AppError::NodeError("grin: invalid server pubkey hex".into()))?, + ) + .map_err(|_| AppError::NodeError("grin: invalid server pubkey".into()))?; + + // Grin uses the raw ECDH x-coordinate directly as the AES-256 key. + let shared = client_secret.diffie_hellman(&server_pubkey); + let shared_key: [u8; 32] = *shared.raw_secret_bytes().as_ref(); + + *guard = Some(SecureSession { + shared_key, + nonce_counter: 0, + }); + Ok(()) + } + + /// Make an encrypted Owner-API call and deserialize the result as `R`. + pub(super) async fn owner_rpc(&self, method: &str, params: T) -> Result + where + T: Serialize, + R: DeserializeOwned, + { + self.ensure_session().await?; + + let inner_json = serde_json::to_string(&JsonRpcRequest::new(method, params)) + .map_err(|_| AppError::NodeError("grin: failed to encode request".into()))?; + + // Encrypt under the write lock (advances the nonce counter). + let encrypted = { + let mut guard = self.secure_session.write().await; + let session = guard + .as_mut() + .ok_or_else(|| AppError::NodeError("grin secure session missing".into()))?; + encrypt(session, &inner_json)? + }; + + let wrapper = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "encrypted_request_v3", + "params": { "nonce": encrypted.nonce, "body_enc": encrypted.body_enc }, + }); + let bytes = self + .post_capped( + &self.owner_api_url, + Some(("grin", self.owner_api_secret.expose())), + &wrapper, + ) + .await?; + + let outer: RpcEnvelope = serde_json::from_slice(&bytes) + .map_err(|_| AppError::NodeError("grin owner API: invalid response".into()))?; + if let Some(e) = outer.error { + // -32001 = secure session expired: clear it so the next call re-handshakes. + if e.code == -32001 { + *self.secure_session.write().await = None; + return Err(AppError::NodeError( + "grin secure session expired, retry".into(), + )); + } + return Err(AppError::NodeError(format!( + "grin owner API error {}", + e.code + ))); + } + let parts = match outer.result { + Some(GrinOk::Ok(p)) => p, + _ => return Err(AppError::NodeError("grin owner API: no result".into())), + }; + + let decrypted = { + let guard = self.secure_session.read().await; + let session = guard + .as_ref() + .ok_or_else(|| AppError::NodeError("grin secure session missing".into()))?; + decrypt(session, &parts.nonce, &parts.body_enc)? + }; + + let inner: RpcEnvelope = serde_json::from_str(&decrypted) + .map_err(|_| AppError::NodeError("grin wallet: invalid response".into()))?; + envelope_into_result(inner, "grin wallet") + } + + /// Make a plain (unencrypted) node JSON-RPC call and deserialize as `R`. + pub(super) async fn node_rpc( + &self, + url: &str, + auth: Option<(&str, &str)>, + method: &str, + params: T, + ) -> Result + where + T: Serialize, + R: DeserializeOwned, + { + let bytes = self + .post_capped(url, auth, &JsonRpcRequest::new(method, params)) + .await?; + let env: RpcEnvelope = serde_json::from_slice(&bytes) + .map_err(|_| AppError::NodeError("grin node: invalid response".into()))?; + envelope_into_result(env, "grin node") + } + + /// POST a JSON body (optionally basic-auth'd) and return the size-capped + /// response bytes. The body is never logged or echoed into an error. + async fn post_capped( + &self, + url: &str, + auth: Option<(&str, &str)>, + body: &B, + ) -> Result, AppError> { + let mut req = self.http.post(url).json(body); + if let Some((user, pass)) = auth { + req = req.basic_auth(user, Some(pass)); + } + let resp = req.send().await.map_err(|e| { + // Transport-level error (connect/DNS/timeout) — not a response body. + AppError::NodeError(format!("grin request failed: {e}")) + })?; + if !resp.status().is_success() { + return Err(AppError::NodeError(format!( + "grin HTTP {}", + resp.status().as_u16() + ))); + } + read_capped(resp, MAX_GRIN_BODY_BYTES).await + } +} + +/// Map a parsed envelope to `R`, surfacing errors with a static label + code +/// only (never the untrusted error body). +fn envelope_into_result(env: RpcEnvelope, what: &str) -> Result { + if let Some(e) = env.error { + return Err(AppError::NodeError(format!("{what} error {}", e.code))); + } + match env.result { + Some(GrinOk::Ok(r)) => Ok(r), + Some(GrinOk::Err(_)) => Err(AppError::NodeError(format!("{what} returned an error"))), + None => Err(AppError::NodeError(format!("{what}: empty result"))), + } +} + +/// Encrypt a plaintext request body under the session key. Advances the nonce +/// counter with `checked_add` so it can never wrap and reuse a nonce. +fn encrypt(session: &mut SecureSession, plaintext: &str) -> Result { + let counter = session.nonce_counter; + session.nonce_counter = counter + .checked_add(1) + .ok_or_else(|| AppError::NodeError("grin nonce counter exhausted".into()))?; + + // 12-byte AES-GCM nonce: 4 zero bytes || 8-byte big-endian counter. + let mut nonce_bytes = [0u8; 12]; + nonce_bytes[4..12].copy_from_slice(&counter.to_be_bytes()); + + let cipher = Aes256Gcm::new_from_slice(&session.shared_key) + .map_err(|_| AppError::NodeError("grin: cipher init failed".into()))?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes()) + .map_err(|_| AppError::NodeError("grin: encryption failed".into()))?; + + Ok(EncryptedParts { + nonce: hex::encode(nonce_bytes), + body_enc: base64::engine::general_purpose::STANDARD.encode(ciphertext), + }) +} + +/// Decrypt an encrypted response body under the session key. +fn decrypt( + session: &SecureSession, + nonce_hex: &str, + body_enc_b64: &str, +) -> Result { + let nonce_bytes = hex::decode(nonce_hex) + .map_err(|_| AppError::NodeError("grin: invalid nonce hex".into()))?; + if nonce_bytes.len() != 12 { + return Err(AppError::NodeError("grin: invalid nonce length".into())); + } + let ciphertext = base64::engine::general_purpose::STANDARD + .decode(body_enc_b64) + .map_err(|_| AppError::NodeError("grin: invalid ciphertext".into()))?; + + let cipher = Aes256Gcm::new_from_slice(&session.shared_key) + .map_err(|_| AppError::NodeError("grin: cipher init failed".into()))?; + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext.as_slice()) + .map_err(|_| AppError::NodeError("grin: decryption failed".into()))?; + + String::from_utf8(plaintext).map_err(|_| AppError::NodeError("grin: non-UTF8 response".into())) +} + +/// Read a response body, enforcing `cap` as bytes arrive (content-length is +/// attacker-asserted, so it is not trusted). +async fn read_capped(resp: reqwest::Response, cap: usize) -> Result, AppError> { + let mut stream = resp.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| AppError::NodeError("grin read failed".into()))?; + if buf.len().saturating_add(chunk.len()) > cap { + return Err(AppError::NodeError( + "grin response exceeded size limit".into(), + )); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> SecureSession { + SecureSession { + shared_key: [7u8; 32], + nonce_counter: 0, + } + } + + #[test] + fn encrypt_decrypt_round_trips() { + let mut s = session(); + let parts = encrypt(&mut s, r#"{"hello":"grin"}"#).unwrap(); + // Decryption uses the same key + the nonce carried on the wire. + let out = decrypt(&s, &parts.nonce, &parts.body_enc).unwrap(); + assert_eq!(out, r#"{"hello":"grin"}"#); + } + + #[test] + fn nonce_counter_advances_and_is_unique() { + let mut s = session(); + let a = encrypt(&mut s, "a").unwrap(); + let b = encrypt(&mut s, "b").unwrap(); + assert_ne!(a.nonce, b.nonce, "each message must use a fresh nonce"); + assert_eq!(s.nonce_counter, 2); + // First nonce is 4 zero bytes || counter 0. + assert_eq!(a.nonce, "000000000000000000000000"); + assert_eq!(b.nonce, "000000000000000000000001"); + } + + #[test] + fn nonce_exhaustion_errors_not_panics() { + let mut s = SecureSession { + shared_key: [1u8; 32], + nonce_counter: u64::MAX, + }; + // checked_add overflow -> Err, never a wrap/panic (would reuse a nonce). + assert!(encrypt(&mut s, "x").is_err()); + } + + #[test] + fn decrypt_rejects_bad_nonce_and_ciphertext() { + let s = session(); + assert!(decrypt(&s, "zz", "AAAA").is_err()); // bad hex + assert!(decrypt(&s, "00", "AAAA").is_err()); // wrong nonce length + assert!(decrypt(&s, "000000000000000000000000", "!!!!").is_err()); // bad b64 + } + + #[test] + fn wrong_key_fails_authentication() { + let mut s = session(); + let parts = encrypt(&mut s, "secret").unwrap(); + let other = SecureSession { + shared_key: [9u8; 32], + nonce_counter: 0, + }; + // GCM auth tag must reject decryption under a different key. + assert!(decrypt(&other, &parts.nonce, &parts.body_enc).is_err()); + } + + #[test] + fn envelope_maps_ok_err_empty() { + let ok: RpcEnvelope = serde_json::from_str(r#"{"result":{"Ok":42}}"#).unwrap(); + assert_eq!(envelope_into_result(ok, "t").unwrap(), 42); + + let err: RpcEnvelope = serde_json::from_str(r#"{"result":{"Err":"nope"}}"#).unwrap(); + assert!(envelope_into_result(err, "t").is_err()); + + let rpc_err: RpcEnvelope = + serde_json::from_str(r#"{"error":{"code":-32000,"message":"boom"}}"#).unwrap(); + let e = envelope_into_result(rpc_err, "t").unwrap_err(); + // The untrusted message is not surfaced; only a static label + code. + match e { + AppError::NodeError(m) => { + assert!(m.contains("-32000")); + assert!(!m.contains("boom")); + } + other => panic!("expected NodeError, got {other:?}"), + } + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 6c3edf8..189b1b9 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -3,4 +3,5 @@ pub mod db; pub mod electrum; +pub mod grin; pub mod lws;