From 9f3380ca90107424a3e01b00f2e97a0621fe0ff6 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:31:05 -0400 Subject: [PATCH] grin1 rail Phase 1 complete: in-process arti onion service (GRIM tor.rs port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gp-server/src/tor.rs — the GRIM onion-service pattern on the same arti 0.43 stack (grim/src/tor/tor.rs: start_service ~:507 -> spawn/run, run_service_proxy ~:766 -> the hsrproxy setup, add_service_key ~:819 -> add_service_key): - The till wallet's index-0 slatepack address key IS the onion identity (HsIdKeypair via the standard SHA-512 seed expansion), so grin1 address == onion address. gp-wallet exposes slatepack_secret_seed() + slatepack_address_pubkey(); unit tests prove seed -> address-pubkey and onion(seed) == onion(grin1-decoded pubkey) equivalence both ways. - One stable service, launched at boot when GP_GRIN1_RAIL=on and a wallet is loaded; proxies onion:80 -> 127.0.0.1:GP_GRIN1_FOREIGN_PORT (the Foreign API v2). Arti state/cache/keystore under /tor/, 0700 (fs-mistrust). Startup logs the onion identity before bootstrap and again once running. - Scope cuts vs GRIM, deliberate: no bridges/pt-client (server), no restart supervisor (systemd owns the process lifecycle). tests/tor_roundtrip.rs (#[ignore], network): REAL Tor round trip — real foreign handler on loopback, onion service with a throwaway key, SEPARATE arti client dials the .onion over the live Tor network and POSTs check_version. VERIFIED PASSING locally in 44.5s: reply {"id":1,"jsonrpc":"2.0","result":{"foreign_api_version":2,"supported_slate_versions":["V4"]}} --- crates/gp-server/Cargo.toml | 28 +++ crates/gp-server/src/lib.rs | 1 + crates/gp-server/src/main.rs | 42 +++- crates/gp-server/src/tor.rs | 305 ++++++++++++++++++++++++ crates/gp-server/tests/tor_roundtrip.rs | 221 +++++++++++++++++ crates/gp-wallet/src/lib.rs | 41 ++++ 6 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 crates/gp-server/src/tor.rs create mode 100644 crates/gp-server/tests/tor_roundtrip.rs diff --git a/crates/gp-server/Cargo.toml b/crates/gp-server/Cargo.toml index c227482..9fabb65 100644 --- a/crates/gp-server/Cargo.toml +++ b/crates/gp-server/Cargo.toml @@ -37,6 +37,28 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p # Stderr logger for the gp-nostr `log` output; no regex filtering needed. env_logger = { version = "0.11", default-features = false, features = ["humantime"] } +# grin1 rail onion transport (Phase 1): the GRIM tor stack, versions matched to +# grim/Cargo.toml (grim/src/tor/tor.rs is the reference; its start_service / +# run_service_proxy / add_service_key are ported in src/tor.rs). +# `onion-service-client` is needed by the round-trip self-check (dialing our own +# .onion); `pt-client` (bridges) deliberately not pulled in — a server has no +# censor to evade. +arti-client = { version = "0.43.0", features = ["static", "onion-service-service", "onion-service-client"] } +tor-rtcompat = { version = "0.43.0", features = ["static"] } +fs-mistrust = "0.14.2" +tor-hsservice = "0.43.0" +tor-hsrproxy = "0.43.0" +tor-keymgr = "0.43.0" +tor-llcrypto = "0.43.0" +tor-hscrypto = "0.43.0" +safelog = "0.8.1" +futures = "0.3" +# The onion identity expansion (ed25519 seed -> expanded keypair), exactly +# GRIM's: SHA-512 the 32-byte address seed into dalek's hazmat expanded form. +# dalek 2 here coexists with gp-wallet's pinned 1.0.1 (different major line). +sha2 = "0.10" +ed25519-dalek = { version = "2.1", features = ["hazmat"] } + [dev-dependencies] # The milestone-3 end-to-end test: a stand-in payer built from nostr-sdk # gift-wraps a REAL S1 (generated by the gp-goblin-sender subprocess), the @@ -48,3 +70,9 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } rand = "0.6" grin_keychain = "=5.4.1" grin_core = "=5.4.1" +# The real-Tor round-trip self-check (tests/tor_roundtrip.rs): hyper drives +# HTTP/1.1 over the arti DataStream to our own onion, GRIM's client pattern. +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" +bytes = "1" diff --git a/crates/gp-server/src/lib.rs b/crates/gp-server/src/lib.rs index 15be24a..90e9851 100644 --- a/crates/gp-server/src/lib.rs +++ b/crates/gp-server/src/lib.rs @@ -11,4 +11,5 @@ pub mod ingest; pub mod invoices; pub mod payments; pub mod record; +pub mod tor; pub mod webhookd; diff --git a/crates/gp-server/src/main.rs b/crates/gp-server/src/main.rs index 86ea113..b2235bb 100644 --- a/crates/gp-server/src/main.rs +++ b/crates/gp-server/src/main.rs @@ -12,7 +12,7 @@ use gp_nostr::{KeyDirectory, Keys}; use gp_server::directory::{self, DbKeyDirectory}; use gp_server::ingest::WalletReceiver; use gp_server::payments::{self, ReceiptSigner}; -use gp_server::{admin, checkout, foreign, invoices, webhookd}; +use gp_server::{admin, checkout, foreign, invoices, tor, webhookd}; use gp_wallet::GpWallet; /// Landing page ("GoblinPay"). @@ -251,12 +251,12 @@ async fn main() -> io::Result<()> { let cfg_data = web::Data::new(cfg.clone()); let wallet_data = web::Data::new(wallet_opt); - // grin1 rail (Phase 1): the Grin Foreign API v2 on loopback, which the onion - // service proxies (onion:80 -> 127.0.0.1:). Only started when the rail - // is armed and a wallet is loaded; a stock Grin sender's receive/finalize - // lands here. The onion transport itself is provisioned at deploy time (see - // deploy/ and the grim tor.rs port); the wallet's index-0 slatepack address - // key IS the onion identity, so grin1 address == onion address. + // grin1 rail (Phase 1): the Grin Foreign API v2 on loopback, plus the + // in-process arti onion service proxying onion:80 -> 127.0.0.1:. + // Only started when the rail is armed and a wallet is loaded; a stock Grin + // sender's receive/finalize lands here over Tor. The wallet's index-0 + // slatepack address key IS the onion identity, so grin1 address == onion + // address (one key, two encodings). if cfg.grin1_rail { if let Some(wallet) = wallet_data.get_ref().as_ref() { // Expiry sweep: cancel the stored context of expired grin1 invoices @@ -279,6 +279,34 @@ async fn main() -> io::Result<()> { Ok(srv) => { println!("grin1 Foreign API v2 listening on http://{foreign_bind}/v2/foreign"); actix_web::rt::spawn(srv.run()); + + // The onion transport: arti state/keystore under the data + // dir, service identity = the wallet's index-0 address key. + match wallet.slatepack_secret_seed() { + Ok(seed) => { + let onion = tor::onion_address_from_seed(&seed); + println!( + "grin1 onion identity: http://{onion}/v2/foreign \ + (same key as the grin1 slatepack address; bootstrapping tor)" + ); + let rx = tor::spawn( + std::path::PathBuf::from(&cfg.data_dir), + seed, + cfg.grin1_foreign_port, + ); + // Report the launch outcome without blocking boot. + std::thread::spawn(move || match rx.recv() { + Ok(Ok(addr)) => { + println!("grin1 onion service running at http://{addr}") + } + Ok(Err(e)) => eprintln!("grin1 onion service failed: {e}"), + Err(_) => { + eprintln!("grin1 onion service exited before reporting") + } + }); + } + Err(e) => eprintln!("grin1: cannot read onion identity key: {e}"), + } } Err(e) => eprintln!("grin1 Foreign API bind {foreign_bind} failed: {e}"), } diff --git a/crates/gp-server/src/tor.rs b/crates/gp-server/src/tor.rs new file mode 100644 index 0000000..e4f9398 --- /dev/null +++ b/crates/gp-server/src/tor.rs @@ -0,0 +1,305 @@ +//! The grin1 rail's onion transport: an in-process arti Tor client publishing +//! ONE stable onion service whose identity IS the till wallet's index-0 +//! slatepack address key, proxying `onion:80 -> 127.0.0.1:` +//! (the loopback Grin Foreign API v2 in [`crate::foreign`]). +//! +//! Because the slatepack (`grin1`) address and a v3 onion address are both just +//! encodings of the same ed25519 public key, a payer's wallet can derive the +//! `.onion` endpoint from the `grin1` address alone: grin1 address == onion +//! address, one key. [`onion_address_from_pubkey`] / +//! [`onion_address_from_seed`] make that equivalence testable. +//! +//! This is a direct port of GRIM's onion-service pattern +//! (`grim/src/tor/tor.rs`): `start_service` (~:507) -> [`spawn`], +//! `run_service_proxy` (~:766) -> the proxy setup inside [`run`], and +//! `add_service_key` (~:819) -> [`add_service_key`], on the same arti 0.43 +//! stack. Differences are deliberate scope cuts: no bridges/pluggable +//! transports (a server has no censor to evade), no restart supervisor (the +//! service runs for the process lifetime; systemd restarts the process), and +//! the service identity always comes from the wallet (GRIM also supports +//! keyless services). +//! +//! Arti state/cache/keystore live under `/tor/`; the keystore is +//! arti's default `/keystore`, so the launched service finds the +//! injected HsIdKeypair. + +use std::fs; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use arti_client::config::TorClientConfigBuilder; +use arti_client::TorClient; +use ed25519_dalek::hazmat::ExpandedSecretKey; +use fs_mistrust::Mistrust; +use log::{error, info}; +use safelog::DisplayRedacted; +use sha2::{Digest, Sha512}; +use tor_hscrypto::pk::{HsId, HsIdKey, HsIdKeypair}; +use tor_hsrproxy::config::{ + Encapsulation, ProxyAction, ProxyConfigBuilder, ProxyPattern, ProxyRule, TargetAddr, +}; +use tor_hsrproxy::OnionServiceReverseProxy; +use tor_hsservice::config::OnionServiceConfigBuilder; +use tor_hsservice::{HsIdKeypairSpecifier, HsIdPublicKeySpecifier, HsNickname}; +use tor_keymgr::{ArtiNativeKeystore, KeyMgrBuilder, KeystoreSelector}; +use tor_llcrypto::pk::ed25519::ExpandedKeypair; +use tor_rtcompat::tokio::TokioNativeTlsRuntime; +use tor_rtcompat::{SleepProviderExt, ToplevelBlockOn}; + +/// The onion service nickname (keys in the keystore are filed under it). +const NICKNAME: &str = "goblinpay"; + +/// Bootstrap deadline. First bootstrap downloads the consensus; generous. +const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(300); + +/// Expand a 32-byte ed25519 seed into tor's expanded keypair form, exactly as +/// GRIM does (and as ed25519 itself defines): `SHA-512(seed)` -> clamped +/// scalar + hash prefix. The seed here is the wallet's index-0 slatepack +/// address secret, so the resulting public key IS the grin1 address key. +fn expanded_keypair(seed: &[u8; 32]) -> ExpandedKeypair { + let expanded_sk = + ExpandedSecretKey::from_bytes(Sha512::default().chain_update(seed).finalize().as_ref()); + let mut sk_bytes = [0u8; 64]; + sk_bytes[0..32].copy_from_slice(&expanded_sk.scalar.to_bytes()); + sk_bytes[32..64].copy_from_slice(&expanded_sk.hash_prefix); + ExpandedKeypair::from_secret_key_bytes(sk_bytes).expect("valid expanded ed25519 key") +} + +/// The v3 onion address (`<56 chars>.onion`) for a 32-byte ed25519 seed: the +/// address the onion service launched with that seed publishes. Pure key math, +/// no network; used for the startup log and the equivalence tests. +pub fn onion_address_from_seed(seed: &[u8; 32]) -> String { + let kp = expanded_keypair(seed); + let hs_id: HsId = HsIdKey::from(*kp.public()).id(); + let addr = hs_id.display_unredacted().to_string(); + addr +} + +/// The v3 onion address for a raw ed25519 public key (32 bytes) — e.g. the key +/// decoded from a `grin1` slatepack address. Same encoding tor itself uses +/// (rend-spec-v3: base32(pubkey || checksum || version) + ".onion"). +pub fn onion_address_from_pubkey(pubkey: [u8; 32]) -> String { + let hs_id = HsId::from(pubkey); + let addr = hs_id.display_unredacted().to_string(); + addr +} + +/// Save the onion service identity to arti's keystore (GRIM's +/// `add_service_key`, ~:819): insert the HsId public key and expanded keypair +/// under the service nickname, overwriting any previous entry so a re-launch +/// with the same wallet is idempotent. +fn add_service_key( + mistrust: &Mistrust, + seed: &[u8; 32], + keystore_dir: &Path, + nickname: &HsNickname, +) -> Result<(), String> { + let arti_store = ArtiNativeKeystore::from_path_and_mistrust(keystore_dir, mistrust) + .map_err(|e| format!("open keystore {keystore_dir:?}: {e}"))?; + let key_manager = KeyMgrBuilder::default() + .primary_store(Box::new(arti_store)) + .build() + .map_err(|e| format!("build key manager: {e}"))?; + + let expanded_kp = expanded_keypair(seed); + key_manager + .insert( + HsIdKey::from(*expanded_kp.public()), + &HsIdPublicKeySpecifier::new(nickname.clone()), + KeystoreSelector::Primary, + true, + ) + .map_err(|e| format!("insert service public key: {e}"))?; + key_manager + .insert( + HsIdKeypair::from(expanded_kp), + &HsIdKeypairSpecifier::new(nickname.clone()), + KeystoreSelector::Primary, + true, + ) + .map_err(|e| format!("insert service keypair: {e}"))?; + Ok(()) +} + +/// Launch the onion service on a dedicated thread: bootstrap an arti client +/// (state under `/tor/`), inject the wallet seed as the service +/// identity, publish the service, and reverse-proxy `onion:80` to +/// `127.0.0.1:`. The returned channel yields exactly one message: +/// `Ok(onion_address)` once the proxy is accepting rendezvous requests (the +/// descriptor upload continues in the background), or `Err(reason)`. The +/// thread then keeps the service alive for the process lifetime. +pub fn spawn(data_dir: PathBuf, seed: [u8; 32], local_port: u16) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + let tx_err = tx.clone(); + if let Err(e) = thread::Builder::new() + .name("gp-onion".into()) + .spawn(move || { + if let Err(e) = run(&data_dir, &seed, local_port, tx) { + error!("grin1 onion service failed: {e}"); + let _ = tx_err.send(Err(e)); + } + }) + { + error!("grin1 onion service thread failed to start: {e}"); + } + rx +} + +/// The onion service body (runs on the dedicated thread, GRIM's +/// `start_service` + `run_service_proxy` combined). Blocks for the process +/// lifetime on the reverse proxy once launched. +fn run( + data_dir: &Path, + seed: &[u8; 32], + local_port: u16, + tx: mpsc::Sender>, +) -> Result<(), String> { + let tor_dir = data_dir.join("tor"); + let state_dir = tor_dir.join("state"); + let cache_dir = tor_dir.join("cache"); + let keystore_dir = state_dir.join("keystore"); // arti's default keystore location + fs::create_dir_all(&state_dir).map_err(|e| format!("create {state_dir:?}: {e}"))?; + fs::create_dir_all(&cache_dir).map_err(|e| format!("create {cache_dir:?}: {e}"))?; + // arti's fs-mistrust refuses group/world-accessible state (it holds the + // onion identity key). The gp data dir is already 0700; make the tor tree + // explicitly so, matching the wallet-dir posture. + for dir in [&tor_dir, &state_dir, &cache_dir] { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("chmod {dir:?}: {e}"))?; + } + + let mut builder = TorClientConfigBuilder::from_directories(&state_dir, &cache_dir); + builder.address_filter().allow_onion_addrs(true); + let config = builder.build().map_err(|e| format!("tor config: {e}"))?; + + let nickname = + HsNickname::new(NICKNAME.into()).map_err(|e| format!("service nickname: {e}"))?; + add_service_key(config.fs_mistrust(), seed, &keystore_dir, &nickname)?; + let onion_address = onion_address_from_seed(seed); + + let runtime = + TokioNativeTlsRuntime::create().map_err(|e| format!("tor runtime: {e}"))?; + let client = TorClient::with_runtime(runtime.clone()) + .config(config) + .create_unbootstrapped() + .map_err(|e| format!("tor client: {e}"))?; + + let proxy_runtime = runtime.clone(); + let timeout_runtime = runtime.clone(); + runtime.block_on(async move { + info!("grin1 onion: bootstrapping tor (state under {tor_dir:?})"); + timeout_runtime + .timeout(BOOTSTRAP_TIMEOUT, client.bootstrap()) + .await + .map_err(|_| format!("tor bootstrap timed out after {BOOTSTRAP_TIMEOUT:?}"))? + .map_err(|e| format!("tor bootstrap: {e}"))?; + + let service_config = OnionServiceConfigBuilder::default() + .nickname(nickname.clone()) + .build() + .map_err(|e| format!("onion service config: {e}"))?; + let (service, rend_requests) = client + .launch_onion_service(service_config) + .map_err(|e| format!("launch onion service: {e}"))? + .ok_or_else(|| "onion service disabled in arti config".to_string())?; + + // Reverse proxy onion:80 -> 127.0.0.1:local_port (GRIM ~:766). + let addr = SocketAddr::new(IpAddr::from(Ipv4Addr::LOCALHOST), local_port); + let proxy_rule = ProxyRule::new( + ProxyPattern::one_port(80).expect("port 80 is a valid proxy pattern"), + ProxyAction::Forward(Encapsulation::Simple, TargetAddr::Inet(addr)), + ); + let mut proxy_cfg_builder = ProxyConfigBuilder::default(); + proxy_cfg_builder.set_proxy_ports(vec![proxy_rule]); + let proxy = OnionServiceReverseProxy::new( + proxy_cfg_builder + .build() + .map_err(|e| format!("proxy config: {e}"))?, + ); + + info!( + "grin1 onion service running: http://{onion_address}/v2/foreign -> \ + 127.0.0.1:{local_port} (descriptor publish continues in background)" + ); + let _ = tx.send(Ok(onion_address.clone())); + + // Drive the proxy for the process lifetime; keep `service` alive (its + // drop would shut the service down). + let res = proxy + .handle_requests(proxy_runtime, nickname.clone(), rend_requests) + .await; + drop(service); + match res { + Ok(()) => Err("onion service proxy terminated".to_string()), + Err(e) => Err(format!("onion service proxy failed: {e}")), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// grin1 address == onion address, one key: derive the onion address from + /// a REAL wallet's secret seed and, independently, from the ed25519 public + /// key decoded out of its `grin1` slatepack address. Both encodings must + /// name the same identity. + #[test] + fn onion_address_equals_grin1_address_identity() { + use rand::RngCore; + let dir = std::env::temp_dir().join(format!( + "gp-tor-equiv-{}-{}", + std::process::id(), + rand::thread_rng().next_u32() + )); + std::fs::create_dir_all(&dir).unwrap(); + + let mut entropy = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut entropy); + let mnemonic = grin_keychain::mnemonic::from_entropy(&entropy).unwrap(); + let wallet = gp_wallet::GpWallet::open_at( + &dir, + &mnemonic, + "test-password", + "http://127.0.0.1:3413", + grin_core::global::ChainTypes::Mainnet, + ) + .unwrap(); + + let grin1 = wallet.slatepack_address().unwrap(); + assert!(grin1.starts_with("grin1")); + let seed = wallet.slatepack_secret_seed().unwrap(); + + // Path A: seed -> expanded keypair -> onion address (what the service + // publishes). Path B: grin1 address -> ed25519 pubkey -> onion address. + let from_seed = onion_address_from_seed(&seed); + let pubkey = gp_wallet::slatepack_address_pubkey(&grin1).unwrap(); + let from_grin1 = onion_address_from_pubkey(pubkey); + + assert_eq!( + from_seed, from_grin1, + "onion identity derived from the wallet seed must equal the onion \ + encoding of the grin1 address" + ); + assert!(from_seed.ends_with(".onion")); + assert_eq!(from_seed.len(), 56 + ".onion".len()); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The seed expansion matches ed25519: the expanded keypair's public key is + /// the same key dalek derives from the seed (so tor signs for exactly the + /// grin1 identity). + #[test] + fn expanded_keypair_public_matches_dalek_seed_derivation() { + use ed25519_dalek::SigningKey; + let seed = [7u8; 32]; + let kp = expanded_keypair(&seed); + let dalek_pub = SigningKey::from_bytes(&seed).verifying_key(); + assert_eq!(kp.public().to_bytes(), dalek_pub.to_bytes()); + } +} diff --git a/crates/gp-server/tests/tor_roundtrip.rs b/crates/gp-server/tests/tor_roundtrip.rs new file mode 100644 index 0000000..4b954ba --- /dev/null +++ b/crates/gp-server/tests/tor_roundtrip.rs @@ -0,0 +1,221 @@ +//! REAL Tor round trip for the grin1 rail's onion transport (Phase 1 gate): +//! +//! 1. serve the REAL Foreign API v2 handler (`gp_server::foreign`) on a +//! loopback port (no wallet: `check_version` needs none), +//! 2. launch the in-process arti onion service with a THROWAWAY key, +//! proxying onion:80 -> that port (`gp_server::tor`, the GRIM port), +//! 3. from a SEPARATE arti client (own state dirs), dial the `.onion` over +//! the real Tor network and POST a JSON-RPC `check_version`, +//! 4. assert the reply envelope (`result.foreign_api_version == 2`). +//! +//! Network-bound and slow (bootstrap + descriptor publish can take minutes), +//! so `#[ignore]`d out of the normal suite. Run once per change to the tor +//! module: +//! +//! CARGO_TARGET_DIR=~/.cache/gp_target \ +//! cargo test -p gp-server --test tor_roundtrip -- --ignored --nocapture + +use std::net::TcpListener; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use actix_web::{web, App, HttpServer}; +use arti_client::config::TorClientConfigBuilder; +use arti_client::TorClient; +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper_util::rt::TokioIo; +use tor_rtcompat::tokio::TokioNativeTlsRuntime; +use tor_rtcompat::ToplevelBlockOn; + +/// Total deadline for the onion round trip once the service reports running +/// (descriptor publish + client fetch). Generous: fresh v3 descriptors can +/// take a couple of minutes to become fetchable. +const ROUNDTRIP_DEADLINE: Duration = Duration::from_secs(600); +/// Pause between dial attempts while the descriptor propagates. +const RETRY_PAUSE: Duration = Duration::from_secs(15); + +/// A free loopback port (bind :0, read, drop). +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("bind :0") + .local_addr() + .expect("local addr") + .port() +} + +/// Serve the real Foreign API app (no wallet) on `port`, on its own thread. +fn serve_foreign(port: u16) { + thread::spawn(move || { + actix_web::rt::System::new().block_on(async move { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + gp_core::db::MIGRATOR.run(&pool).await.expect("migrate"); + HttpServer::new(move || { + App::new() + .app_data(web::Data::new(pool.clone())) + .app_data(web::Data::new(gp_core::config::Config::default())) + .app_data(web::Data::new(None::)) + .configure(gp_server::foreign::configure) + }) + .bind(("127.0.0.1", port)) + .expect("bind foreign port") + .run() + .await + .expect("foreign server run"); + }); + }); +} + +/// One dial attempt: connect to `onion:80` through `client`, POST the JSON-RPC +/// `check_version` request to `/v2/foreign`, and return the response body. +async fn post_check_version( + client: &TorClient, + onion: &str, +) -> Result { + let stream = client + .connect((onion, 80)) + .await + .map_err(|e| format!("connect: {e}"))?; + let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) + .await + .map_err(|e| format!("handshake: {e}"))?; + tokio::spawn(async move { + let _ = connection.await; + }); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"check_version","params":[]}"#; + let req = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(format!("http://{onion}/v2/foreign")) + .header("content-type", "application/json") + .header("host", onion) + .body(Full::::from(body)) + .map_err(|e| format!("request build: {e}"))?; + let resp = sender + .send_request(req) + .await + .map_err(|e| format!("send: {e}"))?; + let status = resp.status(); + let bytes = resp + .into_body() + .collect() + .await + .map_err(|e| format!("body: {e}"))? + .to_bytes(); + let text = String::from_utf8_lossy(&bytes).to_string(); + if !status.is_success() { + return Err(format!("http {status}: {text}")); + } + Ok(text) +} + +#[test] +#[ignore = "real Tor network round trip; run explicitly with -- --ignored"] +fn onion_roundtrip_check_version_over_real_tor() { + let _ = env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("info,tor_proto=warn,tor_chanmgr=warn"), + ) + .is_test(false) + .try_init(); + + // Under $HOME, not /tmp: arti's fs-mistrust rejects state dirs with a + // world-writable ancestor (/tmp is 1777). + let home = std::env::var("HOME").expect("HOME set"); + let tmp = std::path::PathBuf::from(home) + .join(".cache") + .join(format!("gp-tor-roundtrip-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + // 1. The real Foreign API on a loopback port. + let port = free_port(); + serve_foreign(port); + + // 2. The onion service with a throwaway key (NOT a wallet key). + let mut seed = [0u8; 32]; + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut seed); + let expected_onion = gp_server::tor::onion_address_from_seed(&seed); + println!("throwaway onion identity: {expected_onion}"); + + let rx: mpsc::Receiver> = + gp_server::tor::spawn(tmp.join("svc"), seed, port); + let onion = rx + .recv_timeout(Duration::from_secs(420)) + .expect("onion service reported nothing within 7 minutes") + .expect("onion service failed to launch"); + assert_eq!( + onion, expected_onion, + "published onion address must equal the seed-derived one" + ); + println!("onion service up: http://{onion}/v2/foreign"); + + // 3. A separate arti client dials the onion over the real network. + let client_state = tmp.join("client/state"); + let client_cache = tmp.join("client/cache"); + std::fs::create_dir_all(&client_state).unwrap(); + std::fs::create_dir_all(&client_cache).unwrap(); + // fs-mistrust wants private state dirs (0700), like the service side. + for d in [ + tmp.join("client"), + client_state.clone(), + client_cache.clone(), + ] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let mut builder = TorClientConfigBuilder::from_directories(&client_state, &client_cache); + builder.address_filter().allow_onion_addrs(true); + let config = builder.build().expect("client tor config"); + + let runtime = TokioNativeTlsRuntime::create().expect("client runtime"); + let rt = runtime.clone(); + let body = runtime.block_on(async move { + let client = tokio::time::timeout( + Duration::from_secs(300), + TorClient::with_runtime(rt) + .config(config) + .create_bootstrapped(), + ) + .await + .expect("client bootstrap timed out") + .expect("client bootstrap failed"); + println!("client bootstrapped; dialing the onion (descriptor may still be publishing)"); + + // Bounded retry loop while the fresh descriptor propagates. + let deadline = Instant::now() + ROUNDTRIP_DEADLINE; + let mut attempt = 0u32; + loop { + attempt += 1; + match post_check_version(&client, &onion).await { + Ok(body) => break body, + Err(e) => { + println!("attempt {attempt}: {e}"); + assert!( + Instant::now() < deadline, + "onion round trip did not succeed within {ROUNDTRIP_DEADLINE:?} \ + (last error: {e})" + ); + tokio::time::sleep(RETRY_PAUSE).await; + } + } + } + }); + + // 4. The JSON-RPC reply is the real Foreign API check_version envelope. + println!("onion round-trip response: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON-RPC reply"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 1); + assert_eq!( + v["result"]["foreign_api_version"], 2, + "check_version over the onion returns the Foreign API v2 envelope" + ); + assert!(v["result"]["supported_slate_versions"].is_array()); + + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/crates/gp-wallet/src/lib.rs b/crates/gp-wallet/src/lib.rs index 5b3d34b..cad972a 100644 --- a/crates/gp-wallet/src/lib.rs +++ b/crates/gp-wallet/src/lib.rs @@ -52,6 +52,16 @@ pub fn check_version() -> VersionInfo { foreign::check_version() } +/// Decode a `grin1...` slatepack address into its ed25519 public key bytes +/// (32), using grin-wallet's own bech32 decoder. This is the same key the +/// onion service publishes as its identity, so the tor module can prove +/// grin1 address == onion address. +pub fn slatepack_address_pubkey(addr: &str) -> Result<[u8; 32], WalletError> { + let sp = grin_wallet_libwallet::SlatepackAddress::try_from(addr.trim()) + .map_err(|e| WalletError::Slatepack(format!("bad slatepack address `{addr}`: {e}")))?; + Ok(sp.pub_key.to_bytes()) +} + /// The wallet instance type this crate drives (upstream grin-wallet stack). type Provider = DefaultLCProvider<'static, HTTPNodeClient, ExtKeychain>; type Instance = Arc>>>; @@ -269,6 +279,18 @@ impl GpWallet { Ok(addr.to_string()) } + /// The ed25519 SEED of the index-0 slatepack address key (32 bytes). This + /// is the onion-service identity for the grin1 rail: the same key behind + /// the `grin1` slatepack address is injected into arti's keystore as the + /// HsIdKeypair, so grin1 address == onion address (the GRIM pattern). + /// Money-adjacent secret: callers hand it straight to the keystore and + /// never log or persist it anywhere else. + pub fn slatepack_secret_seed(&self) -> Result<[u8; 32], WalletError> { + let d_skey = + owner::get_slatepack_secret_key(self.instance.clone(), self.mask.as_ref(), 0)?; + Ok(d_skey.to_bytes()) + } + /// Receive a payment: parse the S1 slatepack (plain or encrypted to our /// address), run `receive_tx` (offline), and return the S2 reply armor. pub fn receive_slatepack(&self, s1_armor: &str) -> Result { @@ -733,6 +755,25 @@ mod tests { assert!(matches!(err, Err(WalletError::Slatepack(_)))); } + #[test] + fn slatepack_secret_seed_derives_the_address_pubkey() { + // The index-0 seed handed to the onion keystore must be the exact key + // behind the wallet's grin1 slatepack address (grin1 == onion identity). + use ed25519_dalek::{PublicKey as DalekPublicKey, SecretKey as DalekSecretKey}; + let dir = TempDir::new("seed"); + let wallet = open(&dir, &random_mnemonic()).unwrap(); + let seed = wallet.slatepack_secret_seed().unwrap(); + let derived = DalekPublicKey::from(&DalekSecretKey::from_bytes(&seed).unwrap()); + let addr_pub = slatepack_address_pubkey(&wallet.slatepack_address().unwrap()).unwrap(); + assert_eq!(derived.to_bytes(), addr_pub); + } + + #[test] + fn bad_slatepack_address_is_rejected() { + assert!(slatepack_address_pubkey("grin1notanaddress").is_err()); + assert!(slatepack_address_pubkey("").is_err()); + } + #[test] fn finalize_invoice_rejects_garbage_armor() { // Parse-before-node: a malformed I2 armor is rejected without any node