mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat: grin view-only client (non-custodial, hardened secure session)
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).
This commit is contained in:
@@ -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<String, AppError> {
|
||||
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<String>, password).
|
||||
let token: String = self
|
||||
.owner_rpc(
|
||||
"open_wallet",
|
||||
(Option::<String>::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<u64>,
|
||||
) -> Result<ViewWallet, AppError> {
|
||||
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<u64>,
|
||||
) -> Result<ViewWallet, AppError> {
|
||||
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<GrinStatus, AppError> {
|
||||
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<u64, AppError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Option<String>>,
|
||||
/// ECDH/AES-GCM secure session (non-poisoning async lock).
|
||||
secure_session: RwLock<Option<SecureSession>>,
|
||||
}
|
||||
|
||||
impl GrinClient {
|
||||
/// Build a view-only Grin client from config. No network I/O until a call.
|
||||
pub fn new(cfg: &GrinConfig) -> Result<Self, AppError> {
|
||||
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<ViewWalletOutputResult>,
|
||||
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,
|
||||
}
|
||||
@@ -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<R> {
|
||||
result: Option<GrinOk<R>>,
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
/// Grin's externally-tagged result. Deserializing `R` directly (no
|
||||
/// `serde_json::Value` intermediate) avoids cloning a large `ViewWallet`.
|
||||
#[derive(Deserialize)]
|
||||
enum GrinOk<R> {
|
||||
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": "<server pubkey hex>"}`.
|
||||
let env: RpcEnvelope<String> = 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<T, R>(&self, method: &str, params: T) -> Result<R, AppError>
|
||||
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<EncryptedParts> = 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<R> = 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<T, R>(
|
||||
&self,
|
||||
url: &str,
|
||||
auth: Option<(&str, &str)>,
|
||||
method: &str,
|
||||
params: T,
|
||||
) -> Result<R, AppError>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let bytes = self
|
||||
.post_capped(url, auth, &JsonRpcRequest::new(method, params))
|
||||
.await?;
|
||||
let env: RpcEnvelope<R> = 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<B: Serialize>(
|
||||
&self,
|
||||
url: &str,
|
||||
auth: Option<(&str, &str)>,
|
||||
body: &B,
|
||||
) -> Result<Vec<u8>, 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<R>(env: RpcEnvelope<R>, what: &str) -> Result<R, AppError> {
|
||||
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<EncryptedParts, AppError> {
|
||||
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<String, AppError> {
|
||||
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<Vec<u8>, AppError> {
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut buf: Vec<u8> = 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<u64> = serde_json::from_str(r#"{"result":{"Ok":42}}"#).unwrap();
|
||||
assert_eq!(envelope_into_result(ok, "t").unwrap(), 42);
|
||||
|
||||
let err: RpcEnvelope<u64> = serde_json::from_str(r#"{"result":{"Err":"nope"}}"#).unwrap();
|
||||
assert!(envelope_into_result(err, "t").is_err());
|
||||
|
||||
let rpc_err: RpcEnvelope<u64> =
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@
|
||||
|
||||
pub mod db;
|
||||
pub mod electrum;
|
||||
pub mod grin;
|
||||
pub mod lws;
|
||||
|
||||
Reference in New Issue
Block a user