grin1 rail Phase 1 complete: in-process arti onion service (GRIM tor.rs port)

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 <GP_DATA_DIR>/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"]}}
This commit is contained in:
2ro
2026-07-04 22:31:05 -04:00
parent 34a44043f2
commit 9f3380ca90
6 changed files with 631 additions and 7 deletions
+28
View File
@@ -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"
+1
View File
@@ -11,4 +11,5 @@ pub mod ingest;
pub mod invoices;
pub mod payments;
pub mod record;
pub mod tor;
pub mod webhookd;
+35 -7
View File
@@ -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:<port>). 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:<port>.
// 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}"),
}
+305
View File
@@ -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:<foreign port>`
//! (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 `<GP_DATA_DIR>/tor/`; the keystore is
//! arti's default `<state>/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 `<data_dir>/tor/`), inject the wallet seed as the service
/// identity, publish the service, and reverse-proxy `onion:80` to
/// `127.0.0.1:<local_port>`. 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<Result<String, String>> {
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, String>>,
) -> 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());
}
}
+221
View File
@@ -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::<gp_wallet::GpWallet>))
.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<TokioNativeTlsRuntime>,
onion: &str,
) -> Result<String, String> {
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::<Bytes>::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<Result<String, String>> =
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);
}
+41
View File
@@ -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<Mutex<Box<dyn WalletInst<'static, Provider, HTTPNodeClient, ExtKeychain>>>>;
@@ -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<Received, WalletError> {
@@ -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