diff --git a/README.md b/README.md index afbb8d0..0dc9d17 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,32 @@ starforge wallet rotate alice --fund Wallet rotation keeps the same local wallet name in `~/.starforge/config.toml`, but it creates a brand-new on-chain Stellar account keypair. Any scripts, signer sets, or deployment flows that referenced the previous public key still need to be updated separately. +### Multisig ceremonies (treasury / governance transactions) + +Coordinate an M-of-N signing session as a single portable file, so no one machine ever needs network access *and* enough signing authority to submit alone: + +```bash +# Coordinator: build the unsigned transaction + manifest into one file +starforge multisig ceremony start \ + --source GTREASURY... \ + --op '{"type":"payment","to":"GDEST...","amount":"5000"}' \ + --threshold 3 --signers GALICE...,GBOB...,GCAROL...,GDAVE... \ + --network mainnet --output payout.ceremony + +# Each signer (can be on a separate, air-gapped machine): copy the file over +# (USB drive, QR export/import, or a shared repo/PR) and run +starforge multisig ceremony sign --input payout.ceremony --wallet alice +starforge multisig ceremony sign --input payout.ceremony --wallet bob --hardware ledger + +# Anyone: check progress before submitting +starforge multisig ceremony status --input payout.ceremony + +# Once the threshold is met: assemble and submit +starforge multisig ceremony submit --input payout.ceremony +``` + +See [docs/multisig-ceremony.md](docs/multisig-ceremony.md) for a full multi-machine walkthrough, including the air-gapped/USB-transfer workflow and the tamper-detection model. + ### Batch payout commands (airdrops & contributor payments) Pay hundreds or thousands of recipients from a CSV file with checkpointing, resume support, and fee-bump retry. diff --git a/docs/multisig-ceremony.md b/docs/multisig-ceremony.md new file mode 100644 index 0000000..f8c9e2a --- /dev/null +++ b/docs/multisig-ceremony.md @@ -0,0 +1,204 @@ +# Multisig Ceremonies + +`starforge multisig ceremony` coordinates an M-of-N Stellar transaction across +independent signer machines using a single portable file — no shared process +state, no server, no single machine that ever holds enough signing authority +to submit alone. + +This builds on the account-level primitives in `starforge wallet multisig` +(setting up on-chain signer weights and thresholds via `SetOptions`). Ceremonies +are for the *transaction* side: getting a specific payment or operation signed +by enough of those signers before it's submitted. + +## Why a ceremony file + +A traditional multisig flow passes a raw XDR blob between signers and hopes +everyone applies their signature to the same, unmodified transaction. A +ceremony file instead bundles: + +- the unsigned transaction envelope XDR +- a **manifest**: source account, required signers, threshold, network, and + expiry (transaction time bounds) +- a **signature log**: who signed, when, and whether via a hardware wallet +- two integrity hashes (see [Tamper detection](#tamper-detection)) that let + every signer verify the file hasn't been altered since the ceremony started + +The file is plain JSON (base64 XDR embedded as a string), so it's diffable and +safe to pass around a shared repository, attach to a pull request for +auditability, transfer via USB drive, or export/import as a QR code for fully +air-gapped signers. + +## Walkthrough: a 3-of-4 treasury payout + +Four signers hold keys to a treasury account: `alice`, `bob`, `carol`, and +`dave`. Any 3 of them must sign before a payout can go out. `bob` keeps his +key on a Ledger device that never touches the network. + +### 1. Coordinator starts the ceremony + +Whoever is coordinating the payout (any one signer, or an ops account) needs +network access once, to fetch the treasury account's current sequence number +and build the unsigned transaction: + +```bash +starforge multisig ceremony start \ + --source GTREASURY... \ + --op '{"type":"payment","to":"GVENDOR...","amount":"5000","asset":"XLM"}' \ + --threshold 3 \ + --signers GALICE...,GBOB...,GCAROL...,GDAVE... \ + --network mainnet \ + --expires-in-minutes 1440 \ + --output payout.ceremony +``` + +`--op` accepts inline JSON or a path to a JSON file with the same shape +(currently `{"type":"payment","to":...,"amount":...,"asset":...}`; `asset` +defaults to `XLM`, or use `CODE:ISSUER`). `--expires-in-minutes` sets the +transaction's time bounds — pass `0` for no expiry. + +This writes `payout.ceremony`, which is safe to commit to a private repo, post +in a PR, or copy to a USB drive. It contains no secrets. + +### 2. Each signer signs independently + +Every signer gets a copy of `payout.ceremony` — over the network, via a shared +repo, or by sneakernet to an air-gapped machine — and runs `sign` locally. +**No network access is required to sign.** + +```bash +# alice, on her own machine +starforge multisig ceremony sign --input payout.ceremony --wallet alice --output alice.ceremony + +# carol, on a separate machine +starforge multisig ceremony sign --input payout.ceremony --wallet carol --output carol.ceremony + +# bob, using a connected Ledger instead of a local secret key +starforge multisig ceremony sign --input payout.ceremony --wallet bob --hardware ledger --output bob.ceremony +``` + +Each invocation is stateless: it re-derives everything it needs from the input +file, so it doesn't matter whether signers run this sequentially against one +shared file or in parallel against their own copies (see +[Merging independent copies](#merging-independent-copies-air-gapped--usb-workflow) +below for combining the latter). + +`--wallet ` identifies a locally registered wallet (`starforge wallet +create`/`import`). With `--hardware `, the signature comes from +the connected device instead of a stored secret key; the device's own derived +address is what gets checked against the ceremony's required signer list. + +### 3. Check status before submitting + +Anyone with a copy of the file — including someone with no signing authority +at all — can check progress: + +```bash +starforge multisig ceremony status --input payout.ceremony +``` + +``` + Signatures 2 of 4 required (threshold 3) + ✓ signed GALICE... + … waiting GBOB... + ✓ signed GCAROL... + … waiting GDAVE... + +⚠ Waiting on 1 more signature(s) + Expires At 2026-08-01T12:00:00+00:00 + Time Remaining 18h 42m 10s +``` + +### 4. Submit once the threshold is met + +```bash +starforge multisig ceremony submit --input payout.ceremony +``` + +`submit` re-verifies everything before touching the network: + +- the manifest and transaction body haven't been tampered with +- at least `threshold` signatures are present, all from signers in the + manifest's required-signer list +- the transaction hasn't expired + +Only then does it assemble the final envelope and submit it to Horizon. If any +check fails, nothing is submitted and the error explains exactly what's +missing (e.g. `Not enough signatures to submit: 2 of 4 required signatures +collected (threshold 3). Outstanding signers: GDAVE...`). + +## Merging independent copies (air-gapped / USB workflow) + +For fully air-gapped signers, the coordinator copies `payout.ceremony` onto a +USB drive (or exports it as a QR code) for each signer, who signs on their +offline machine and hands back a signed copy. Because each `sign` invocation +only *adds* one signature to the file it's given, merge multiple independently +signed copies by re-applying each signer's step against a single accumulating +file rather than needing them all signed in one physical file at once: + +```bash +# Coordinator, after collecting alice.ceremony, bob.ceremony, carol.ceremony +# back from USB transfers: +cp payout.ceremony merged.ceremony + +# For each returned copy, the signer's own hardware/wallet re-signs the +# accumulating file (fast — signing an already-signed transaction with the +# same key is a no-op) or, if you trust the returned files directly, take the +# transaction_envelope_xdr field's signatures from each copy and append them +# in sequence with `ceremony sign` runs against `merged.ceremony`. +``` + +In practice, the simplest reliable pattern is to have each signer's `sign` +step performed directly against a copy of the file that already carries the +prior signers' signatures — pass the ceremony file along a chain (alice → +carol → bob) rather than fanning it out and merging independently-signed +forks. `ceremony sign`'s idempotent, stateless design supports both: signing +twice with the same key is a safe no-op either way (see +[Duplicate signers](#duplicate-signers)). + +## Tamper detection + +Every `sign`, `status`, and `submit` call recomputes two hashes from the +current file contents and compares them against the values stored at +`ceremony start`: + +- **`tx_body_hash`** — a SHA-256 of the unsigned transaction body (source + account, sequence number, operations, memo, and time bounds — everything + except signatures). If anyone edits the operation, amount, destination, or + sequence number after the ceremony started, this hash no longer matches and + every subsequent `sign`/`status`/`submit` is refused. +- **`manifest_hash`** — a SHA-256 over the required signer set, threshold, + network, and expiry. This catches tampering with the ceremony's *rules* + (e.g. someone lowering the threshold from 3 to 1, or removing a required + signer) independently of the transaction body. + +``` +✗ Error: Transaction integrity check failed: the unsigned transaction body +(source, sequence, operations, or preconditions) was altered after the +ceremony started. Refusing to sign or submit a tampered ceremony file. +``` + +`submit` additionally cross-checks the signature log against the raw XDR +envelope's actual signature count and rejects any signature attributed to a +signer outside the manifest's required-signer list — so a hand-edited +`signature_log` can't claim a threshold that isn't backed by real signatures. + +These are integrity checks, not a substitute for reviewing the transaction. +Each signer should still confirm the operation summary printed by `sign` +matches what they expect before approving on their device or entering a +passphrase. + +## Duplicate signers + +Signing with the same key twice is a no-op: `sign` reports "had already +signed this ceremony; no change made" and the signature log and collected +count are unaffected. This makes it safe to re-run `sign` defensively (e.g. +after an interrupted USB transfer) without double-counting. + +## Expiry + +`--expires-in-minutes` sets the transaction's time bounds (`min_time: 0`, +`max_time: now + N minutes`) at `ceremony start`. Once that window passes, +`status` reports the ceremony as expired and `submit` refuses to send it — +start a new ceremony instead of trying to extend the deadline of an existing +one (extending it would itself be a transaction-body change, caught by tamper +detection above). diff --git a/src/commands/command_tree.rs b/src/commands/command_tree.rs index ecf4337..b9b63f4 100644 --- a/src/commands/command_tree.rs +++ b/src/commands/command_tree.rs @@ -156,6 +156,16 @@ const COMMANDS: &[CmdEntry] = &[ ("update", "Update a plugin to a newer version"), ], }, + CmdEntry { + name: "multisig", + about: "Coordinate M-of-N multisig ceremonies as a portable file", + subs: &[ + ("ceremony start", "Build the unsigned transaction + manifest into one file"), + ("ceremony sign", "Add this signer's signature (no network access required)"), + ("ceremony status", "Show collected vs. required signatures and time to expiry"), + ("ceremony submit", "Verify the threshold is met and submit the transaction"), + ], + }, CmdEntry { name: "shell", about: "Interactive REPL for local Soroban contract testing", diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 16cd031..4821fd4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod inspect; pub mod invoke; pub mod lint; pub mod monitor; +pub mod multisig_ceremony; pub mod network; pub mod new; pub mod node; diff --git a/src/commands/multisig_ceremony.rs b/src/commands/multisig_ceremony.rs new file mode 100644 index 0000000..301fa93 --- /dev/null +++ b/src/commands/multisig_ceremony.rs @@ -0,0 +1,436 @@ +//! `starforge multisig ceremony ...` — orchestrated M-of-N signing sessions +//! carried as a single portable file across independent (potentially +//! air-gapped) signer machines. See `crate::utils::ceremony` for the file +//! format and integrity model. + +use crate::utils::hardware_wallet::HardwareWalletKind; +use crate::utils::tx_batch::BatchOperation; +use crate::utils::{ceremony, config, confirmation, crypto, horizon, print as p}; +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use colored::*; +use std::path::PathBuf; + +#[derive(Subcommand)] +pub enum MultisigCommands { + /// Coordinate a multi-party (M-of-N) signing session as a portable file + #[command(subcommand)] + Ceremony(CeremonyCommands), +} + +#[derive(Subcommand)] +pub enum CeremonyCommands { + /// Start a ceremony: build the unsigned transaction + manifest into one file + /// + /// Example: + /// starforge multisig ceremony start --source G... --op '{"type":"payment","to":"G...","amount":"100"}' \ + /// --threshold 3 --signers G1,G2,G3,G4 --output tx.ceremony + Start(StartArgs), + /// Add this signer's signature to a ceremony file (no network access required) + /// + /// Example: + /// starforge multisig ceremony sign --input tx.ceremony --wallet alice --output tx.ceremony + Sign(SignArgs), + /// Show collected vs. required signatures and time remaining before expiry + Status(StatusArgs), + /// Verify the threshold is met and submit the assembled transaction + Submit(SubmitArgs), +} + +#[derive(Args)] +pub struct StartArgs { + /// Source account for the transaction (G...) + #[arg(long)] + pub source: String, + /// Operation spec: inline JSON (e.g. '{"type":"payment","to":"G...","amount":"100"}') + /// or a path to a JSON file containing the same object. + #[arg(long)] + pub op: String, + /// Number of signatures required before the transaction can be submitted + #[arg(long)] + pub threshold: u8, + /// Comma-separated list of required signer public keys (G1,G2,G3,...) + #[arg(long)] + pub signers: String, + /// Network to build the transaction for + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet", "docker-testnet"])] + pub network: String, + /// Minutes until the transaction's time bounds expire (omit for no expiry) + #[arg(long, default_value = "60")] + pub expires_in_minutes: i64, + /// Output path for the ceremony file + #[arg(long)] + pub output: PathBuf, +} + +#[derive(Args)] +pub struct SignArgs { + /// Path to the ceremony file + #[arg(long)] + pub input: PathBuf, + /// Local wallet name to sign with + #[arg(long)] + pub wallet: String, + /// Sign using a connected hardware wallet instead of the wallet's local secret key + #[arg(long)] + pub hardware: Option, + /// Output file (defaults to in-place update) + #[arg(long)] + pub output: Option, +} + +#[derive(Args)] +pub struct StatusArgs { + /// Path to the ceremony file + #[arg(long)] + pub input: PathBuf, +} + +#[derive(Args)] +pub struct SubmitArgs { + /// Path to the ceremony file + #[arg(long)] + pub input: PathBuf, + /// Network to submit on (defaults to the network recorded at ceremony start) + #[arg(long, value_parser = ["testnet", "mainnet", "docker-testnet"])] + pub network: Option, + /// Skip the confirmation prompt + #[arg(long, default_value = "false")] + pub yes: bool, +} + +pub fn handle(cmd: MultisigCommands) -> Result<()> { + match cmd { + MultisigCommands::Ceremony(cmd) => match cmd { + CeremonyCommands::Start(args) => start(args), + CeremonyCommands::Sign(args) => sign(args), + CeremonyCommands::Status(args) => status(args), + CeremonyCommands::Submit(args) => submit(args), + }, + } +} + +fn parse_operation(op: &str) -> Result { + let path = PathBuf::from(op); + let raw = if path.exists() { + std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read operation file {}", path.display()))? + } else { + op.to_string() + }; + serde_json::from_str(&raw).with_context(|| { + "Invalid --op. Expected JSON like {\"type\":\"payment\",\"to\":\"G...\",\"amount\":\"100\"} \ + or a path to a file containing it" + .to_string() + }) +} + +fn start(args: StartArgs) -> Result<()> { + p::header("Multisig Ceremony — Start"); + + config::validate_public_key(&args.source)?; + config::validate_network(&args.network)?; + + let signers: Vec = args + .signers + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if signers.is_empty() { + anyhow::bail!("--signers must list at least one signer public key"); + } + for signer in &signers { + config::validate_public_key(signer) + .with_context(|| format!("Invalid signer public key: {}", signer))?; + } + + let operation = parse_operation(&args.op)?; + crate::utils::tx_batch::validate_batch_operations(std::slice::from_ref(&operation))?; + + let expires_in_minutes = if args.expires_in_minutes > 0 { + Some(args.expires_in_minutes) + } else { + None + }; + + p::step(1, 2, "Fetching source account sequence number…"); + let sequence = horizon::fetch_account_sequence(&args.source, &args.network) + .map_err(|e| anyhow::anyhow!("Failed to fetch source account on {}: {}", args.network, e))? + .to_string(); + + p::step(2, 2, "Building unsigned transaction and manifest…"); + let file = ceremony::build_ceremony( + &args.source, + &operation, + &sequence, + args.threshold, + signers, + &args.network, + expires_in_minutes, + )?; + + ceremony::save_ceremony_file(&args.output, &file)?; + + println!(); + p::success("Ceremony started"); + p::kv("Source", &file.manifest.source_account); + p::kv("Operation", &file.manifest.operation_summary); + p::kv("Network", &file.manifest.network); + p::kv( + "Threshold", + &format!( + "{} of {}", + file.manifest.threshold, + file.manifest.required_signers.len() + ), + ); + for (i, signer) in file.manifest.required_signers.iter().enumerate() { + p::kv(&format!(" Signer {}", i + 1), signer); + } + if let Some(expires_at) = &file.manifest.expires_at { + p::kv("Expires At", expires_at); + } else { + p::kv("Expires At", "never"); + } + p::kv("Manifest Hash", &file.manifest.manifest_hash); + p::kv("Ceremony File", &args.output.display().to_string()); + + println!(); + p::info("Copy this file to each signer (USB drive, QR export, shared repo/PR) and run:"); + println!( + " {}", + format!( + "starforge multisig ceremony sign --input {} --wallet ", + args.output.display() + ) + .cyan() + ); + println!( + " {}", + format!( + "starforge multisig ceremony status --input {}", + args.output.display() + ) + .cyan() + ); + + Ok(()) +} + +fn resolve_local_secret(wallet_name: &str) -> Result { + let cfg = config::load()?; + let wallet = cfg + .wallets + .iter() + .find(|w| w.name == wallet_name) + .ok_or_else(|| { + anyhow::anyhow!( + "Wallet '{}' not found. Run `starforge wallet list`", + wallet_name + ) + })?; + + let secret = wallet + .secret_key + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Wallet '{}' has no local secret key", wallet_name))?; + + if secret.contains(':') { + let pwd = crypto::prompt_password( + &format!("Enter password to decrypt wallet '{}'", wallet_name), + false, + )?; + crypto::decrypt_secret(&pwd, secret) + .map_err(|_| anyhow::anyhow!("Incorrect password or unable to decrypt.")) + } else { + Ok(secret.clone()) + } +} + +fn sign(args: SignArgs) -> Result<()> { + p::header("Multisig Ceremony — Sign"); + config::validate_file_path(&args.input, None)?; + + let mut file = ceremony::load_ceremony_file(&args.input)?; + p::kv("Wallet", &args.wallet); + p::kv("Source", &file.manifest.source_account); + p::kv("Operation", &file.manifest.operation_summary); + + let secret_key = if args.hardware.is_some() { + if let Some(kind) = args.hardware { + p::info(&format!("Requesting signature from connected {}…", kind)); + } + None + } else { + Some(resolve_local_secret(&args.wallet)?) + }; + + let outcome = ceremony::add_signature(&mut file, secret_key.as_deref(), args.hardware)?; + + let output_path = args.output.unwrap_or_else(|| args.input.clone()); + ceremony::save_ceremony_file(&output_path, &file)?; + + println!(); + if outcome.already_signed { + p::warn(&format!( + "Signer '{}' had already signed this ceremony; no change made.", + outcome.signer + )); + } else { + p::success(&format!("Signature recorded for '{}'", outcome.signer)); + } + + let report = ceremony::status(&file)?; + p::kv( + "Signatures", + &format!( + "{} of {} required (threshold {})", + report.collected, report.required_total, report.required_threshold + ), + ); + if !report.outstanding_signers.is_empty() { + p::kv("Outstanding", &report.outstanding_signers.join(", ")); + } + p::kv("Ceremony File", &output_path.display().to_string()); + + Ok(()) +} + +fn status(args: StatusArgs) -> Result<()> { + p::header("Multisig Ceremony — Status"); + config::validate_file_path(&args.input, None)?; + + let file = ceremony::load_ceremony_file(&args.input)?; + let report = ceremony::status(&file)?; + + p::kv("Source", &file.manifest.source_account); + p::kv("Operation", &file.manifest.operation_summary); + p::kv("Network", &file.manifest.network); + p::separator(); + p::kv( + "Signatures", + &format!( + "{} of {} required (threshold {})", + report.collected, report.required_total, report.required_threshold + ), + ); + + for signer in &file.manifest.required_signers { + let signed = report.signed_signers.iter().any(|s| s == signer); + let mark = if signed { + "✓ signed".green().to_string() + } else { + "… waiting".yellow().to_string() + }; + println!(" {} {}", mark, signer.dimmed()); + } + + println!(); + if report.threshold_met { + p::success("Threshold met — ready to submit"); + } else { + p::warn(&format!( + "Waiting on {} more signature(s)", + report.required_threshold as usize - report.collected + )); + } + + match (&report.expires_at, report.seconds_remaining) { + (Some(expires_at), Some(remaining)) if report.expired => { + p::kv("Expired", &format!("at {}", expires_at)); + let _ = remaining; + } + (Some(expires_at), Some(remaining)) => { + p::kv("Expires At", expires_at); + p::kv("Time Remaining", &format_duration(remaining)); + } + _ => { + p::kv("Expires At", "never"); + } + } + + Ok(()) +} + +fn format_duration(seconds: i64) -> String { + let seconds = seconds.max(0); + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{}h {}m {}s", hours, minutes, secs) +} + +fn submit(args: SubmitArgs) -> Result<()> { + p::header("Multisig Ceremony — Submit"); + config::validate_file_path(&args.input, None)?; + + let file = ceremony::load_ceremony_file(&args.input)?; + let network = args + .network + .unwrap_or_else(|| file.manifest.network.clone()); + config::validate_network(&network)?; + + let report = ceremony::status(&file)?; + p::kv("Source", &file.manifest.source_account); + p::kv("Operation", &file.manifest.operation_summary); + p::kv("Network", &network); + p::kv( + "Signatures", + &format!( + "{} of {} required (threshold {})", + report.collected, report.required_total, report.required_threshold + ), + ); + + let signed_xdr = ceremony::assemble_for_submit(&file)?; + + let risk_level = if network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; + let summary = confirmation::OperationSummary::new( + "Submit Multisig Ceremony Transaction".to_string(), + network.clone(), + risk_level, + ) + .add("Source", &file.manifest.source_account) + .add("Operation", &file.manifest.operation_summary) + .add( + "Signatures", + format!("{} of {}", report.collected, report.required_total), + ); + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: network.clone(), + skip_confirm: args.yes, + dry_run: false, + prompt: Some("Submit this ceremony transaction?".to_string()), + require_type_confirmation: network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + return Ok(()); + } + + p::info("Submitting signed envelope to Horizon…"); + let result = horizon::submit_multisig_transaction(&signed_xdr, &network)?; + + println!(); + p::success("Transaction submitted"); + p::kv_accent("Transaction Hash", &result.hash); + + let explorer_base = if network == "mainnet" { + "https://stellar.expert/explorer/public/tx" + } else { + "https://stellar.expert/explorer/testnet/tx" + }; + p::kv( + "Stellar Expert", + &format!("{}/{}", explorer_base, result.hash), + ); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 424ccf8..3de2eb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,10 @@ enum Commands { #[command(subcommand)] Config(commands::config::ConfigCommands), + /// Coordinate M-of-N multisig ceremonies as a portable file (start/sign/status/submit) + #[command(subcommand)] + Multisig(commands::multisig_ceremony::MultisigCommands), + /// Manage telemetry collection #[command(subcommand)] Telemetry(commands::telemetry::TelemetryCommands), @@ -185,6 +189,7 @@ fn main() { Commands::Deploy(_) => "deploy", Commands::Info => "info", Commands::Config(_) => "config", + Commands::Multisig(_) => "multisig", Commands::Telemetry(_) => "telemetry", Commands::Batch(_) => "batch", Commands::Tx(_) => "tx", @@ -216,6 +221,7 @@ fn main() { Commands::Deploy(args) => commands::deploy::handle(args), Commands::Info => commands::info::handle(), Commands::Config(cmd) => commands::config::handle(cmd), + Commands::Multisig(cmd) => commands::multisig_ceremony::handle(cmd), Commands::Telemetry(cmd) => commands::telemetry::handle(cmd), Commands::Batch(args) => commands::batch::handle(args), Commands::Tx(args) => commands::tx::handle(args), diff --git a/src/utils/ceremony.rs b/src/utils/ceremony.rs new file mode 100644 index 0000000..db45c00 --- /dev/null +++ b/src/utils/ceremony.rs @@ -0,0 +1,820 @@ +//! Multisig ceremony: a portable, self-describing file that carries an +//! unsigned transaction through an M-of-N signing process across +//! independent (potentially air-gapped) machines. +//! +//! A ceremony file bundles: +//! - a `manifest` describing the source account, required signers, +//! threshold, expiry, and integrity hashes +//! - the transaction envelope XDR, which accumulates signatures as each +//! signer runs `ceremony sign` against the file +//! - a `signature_log` recording who signed and when (and whether via a +//! hardware wallet), independent of what the raw XDR signature hints +//! reveal +//! +//! Every operation that reads a ceremony file first re-derives its integrity +//! hashes from the current contents and compares them against the stored +//! values, so a file edited (accidentally or otherwise) after the first +//! signature is rejected rather than silently signed or submitted. + +use crate::utils::config; +use crate::utils::hardware_wallet::{self, HardwareWalletKind}; +use crate::utils::multisig; +use crate::utils::tx_batch::BatchOperation; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; +use std::str::FromStr; +use stellar_strkey::ed25519::PublicKey as StellarPublicKey; +use stellar_xdr::curr::{ + AccountId, AlphaNum12, AlphaNum4, Asset, AssetCode12, AssetCode4, BytesM, DecoratedSignature, + Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber, + Signature as XdrSignature, SignatureHint, TimeBounds, TimePoint, Transaction, + TransactionEnvelope, TransactionExt, +}; + +/// Ceremony file format version. Bump when the on-disk schema changes in a +/// way that isn't backwards compatible. +pub const CEREMONY_FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CeremonyManifest { + pub source_account: String, + pub network: String, + pub threshold: u8, + pub required_signers: Vec, + pub operation_summary: String, + pub created_at: String, + pub expires_at: Option, + /// sha256 hex of the unsigned transaction body (no signatures). + pub tx_body_hash: String, + /// sha256 hex of the manifest fields above, used to detect tampering + /// with the ceremony parameters themselves (threshold, signer set, + /// expiry) independent of the transaction body. + pub manifest_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SigningMethod { + Local, + Hardware { device: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CeremonySignatureRecord { + pub signer: String, + pub signed_at: String, + pub method: SigningMethod, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CeremonyFile { + pub version: u32, + pub manifest: CeremonyManifest, + pub transaction_envelope_xdr: String, + pub signature_log: Vec, +} + +#[derive(Debug, Clone)] +pub struct SignOutcome { + pub signer: String, + pub already_signed: bool, +} + +#[derive(Debug, Clone)] +pub struct CeremonyStatusReport { + pub required_threshold: u8, + pub collected: usize, + pub required_total: usize, + pub threshold_met: bool, + pub signed_signers: Vec, + pub outstanding_signers: Vec, + pub expires_at: Option, + pub expired: bool, + pub seconds_remaining: Option, +} + +/// Build a new ceremony: an unsigned transaction envelope for `operation` +/// plus a manifest recording the signers/threshold/expiry that must hold +/// for the lifetime of the ceremony. +#[allow(clippy::too_many_arguments)] +pub fn build_ceremony( + source_account: &str, + operation: &BatchOperation, + sequence: &str, + threshold: u8, + mut required_signers: Vec, + network: &str, + expires_in_minutes: Option, +) -> Result { + if required_signers.is_empty() { + anyhow::bail!("A ceremony requires at least one signer"); + } + if threshold == 0 { + anyhow::bail!("Threshold must be greater than 0"); + } + + required_signers.sort(); + required_signers.dedup(); + + if (threshold as usize) > required_signers.len() { + anyhow::bail!( + "Threshold ({}) exceeds the number of distinct signers provided ({})", + threshold, + required_signers.len() + ); + } + for signer in &required_signers { + config::validate_public_key(signer) + .with_context(|| format!("Invalid signer public key: {}", signer))?; + } + + let expires_at_unix = match expires_in_minutes { + Some(minutes) if minutes > 0 => Some(Utc::now().timestamp() + minutes * 60), + Some(_) => anyhow::bail!("Expiry window must be a positive number of minutes"), + None => None, + }; + + let envelope = + build_transaction_envelope(source_account, operation, sequence, expires_at_unix)?; + let tx_body_hash = hash_tx_body(&envelope)?; + let expires_at = expires_at_unix.map(format_unix_timestamp); + + let mut manifest = CeremonyManifest { + source_account: source_account.to_string(), + network: network.to_string(), + threshold, + required_signers, + operation_summary: describe_operation(operation), + created_at: Utc::now().to_rfc3339(), + expires_at, + tx_body_hash, + manifest_hash: String::new(), + }; + manifest.manifest_hash = manifest_fingerprint(&manifest); + + Ok(CeremonyFile { + version: CEREMONY_FORMAT_VERSION, + manifest, + transaction_envelope_xdr: multisig::encode_transaction_envelope(&envelope)?, + signature_log: Vec::new(), + }) +} + +/// Re-derive the manifest and transaction-body hashes from the current file +/// contents and compare them against the stored values. Any edit to the +/// signer set, threshold, expiry, or transaction body after ceremony start +/// is rejected here. +pub fn verify_integrity(file: &CeremonyFile) -> Result<()> { + if file.version != CEREMONY_FORMAT_VERSION { + anyhow::bail!( + "Unsupported ceremony file version {} (expected {})", + file.version, + CEREMONY_FORMAT_VERSION + ); + } + + let expected_manifest_hash = manifest_fingerprint(&file.manifest); + if expected_manifest_hash != file.manifest.manifest_hash { + anyhow::bail!( + "Ceremony manifest integrity check failed: the required signers, threshold, \ + network, or expiry was modified after the ceremony started. Refusing to sign \ + or submit a tampered ceremony file." + ); + } + + let envelope = multisig::decode_transaction_envelope(&file.transaction_envelope_xdr)?; + let current_tx_body_hash = hash_tx_body(&envelope)?; + if current_tx_body_hash != file.manifest.tx_body_hash { + anyhow::bail!( + "Transaction integrity check failed: the unsigned transaction body (source, \ + sequence, operations, or preconditions) was altered after the ceremony started. \ + Refusing to sign or submit a tampered ceremony file." + ); + } + + Ok(()) +} + +/// Add this signer's signature to the ceremony file. Stateless: everything +/// needed is re-derived from `file` itself, so independent invocations +/// against the same file (e.g. on separate air-gapped machines, merged +/// later) behave identically to sequential invocations on one machine. +pub fn add_signature( + file: &mut CeremonyFile, + secret_key: Option<&str>, + hardware: Option, +) -> Result { + verify_integrity(file)?; + let network = file.manifest.network.clone(); + + let signer_public_key = if let Some(kind) = hardware { + hardware_wallet::get_stellar_address(kind, hardware_wallet::STELLAR_HD_PATH) + .context("Failed to read Stellar address from hardware wallet")? + } else { + let secret_key = secret_key + .ok_or_else(|| anyhow::anyhow!("A secret key or --hardware is required to sign"))?; + let signing_key = multisig::signing_key_from_secret(secret_key)?; + StellarPublicKey(signing_key.verifying_key().to_bytes()).to_string() + }; + + if !file + .manifest + .required_signers + .iter() + .any(|s| s == &signer_public_key) + { + anyhow::bail!( + "Signer '{}' is not among the required signers for this ceremony ({})", + signer_public_key, + file.manifest.required_signers.join(", ") + ); + } + + let (added, method) = if let Some(kind) = hardware { + let mut envelope = multisig::decode_transaction_envelope(&file.transaction_envelope_xdr)?; + let raw_pk = StellarPublicKey::from_string(&signer_public_key) + .map_err(|_| anyhow::anyhow!("Invalid hardware wallet public key"))? + .0; + let hint = SignatureHint([raw_pk[28], raw_pk[29], raw_pk[30], raw_pk[31]]); + let method = SigningMethod::Hardware { + device: kind.to_string(), + }; + + if multisig::envelope_has_signature_hint(&envelope, &hint) { + (false, method) + } else { + let hash = multisig::transaction_signature_hash(&envelope, &network)?; + let raw_sig = + hardware_wallet::sign(kind, &hash).context("Hardware wallet signing failed")?; + let decorated = DecoratedSignature { + hint, + signature: XdrSignature(BytesM::try_from(raw_sig).map_err(|_| { + anyhow::anyhow!("Failed to encode hardware signature as XDR bytes") + })?), + }; + multisig::append_decorated_signature(&mut envelope, decorated)?; + file.transaction_envelope_xdr = multisig::encode_transaction_envelope(&envelope)?; + (true, method) + } + } else { + let secret_key = secret_key.expect("checked above"); + let (signed_xdr, _pk, added) = multisig::sign_transaction_envelope( + &file.transaction_envelope_xdr, + secret_key, + &network, + )?; + file.transaction_envelope_xdr = signed_xdr; + (added, SigningMethod::Local) + }; + + if added { + file.signature_log.push(CeremonySignatureRecord { + signer: signer_public_key.clone(), + signed_at: Utc::now().to_rfc3339(), + method, + }); + } + + Ok(SignOutcome { + signer: signer_public_key, + already_signed: !added, + }) +} + +/// Collected-vs-required signature status, outstanding signers, and expiry +/// countdown for this ceremony file. +pub fn status(file: &CeremonyFile) -> Result { + verify_integrity(file)?; + + let mut signed_signers: Vec = file + .signature_log + .iter() + .map(|record| record.signer.clone()) + .collect(); + signed_signers.sort(); + signed_signers.dedup(); + + let outstanding_signers: Vec = file + .manifest + .required_signers + .iter() + .filter(|signer| !signed_signers.contains(signer)) + .cloned() + .collect(); + + let (expired, seconds_remaining) = match &file.manifest.expires_at { + Some(timestamp) => { + let expiry = DateTime::parse_from_rfc3339(timestamp) + .context("Failed to parse ceremony expiry timestamp")? + .with_timezone(&Utc); + let remaining = (expiry - Utc::now()).num_seconds(); + (remaining <= 0, Some(remaining)) + } + None => (false, None), + }; + + Ok(CeremonyStatusReport { + required_threshold: file.manifest.threshold, + collected: signed_signers.len(), + required_total: file.manifest.required_signers.len(), + threshold_met: signed_signers.len() >= file.manifest.threshold as usize, + signed_signers, + outstanding_signers, + expires_at: file.manifest.expires_at.clone(), + expired, + seconds_remaining, + }) +} + +/// Verify the ceremony is ready to submit and return the final signed +/// envelope XDR. Refuses when signatures are insufficient, when the +/// envelope carries a signature from a signer outside the manifest, when +/// the ceremony has expired, or when integrity checks fail. +pub fn assemble_for_submit(file: &CeremonyFile) -> Result { + let report = status(file)?; + + if report.expired { + anyhow::bail!( + "Ceremony expired at {} — the transaction's time bounds have passed. Start a new ceremony.", + report.expires_at.as_deref().unwrap_or("unknown") + ); + } + + for record in &file.signature_log { + if !file + .manifest + .required_signers + .iter() + .any(|s| s == &record.signer) + { + anyhow::bail!( + "Ceremony file contains a signature from '{}', who is not an authorized signer \ + for this ceremony", + record.signer + ); + } + } + + if !report.threshold_met { + let outstanding = if report.outstanding_signers.is_empty() { + "none".to_string() + } else { + report.outstanding_signers.join(", ") + }; + anyhow::bail!( + "Not enough signatures to submit: {} of {} required signatures collected \ + (threshold {}). Outstanding signers: {}", + report.collected, + report.required_total, + report.required_threshold, + outstanding + ); + } + + // Defense in depth: an edited signature_log claiming a threshold isn't + // enough on its own — the raw envelope must actually carry that many + // decoded signatures. + let envelope_signatures = multisig::signature_count(&file.transaction_envelope_xdr)?; + if envelope_signatures < file.manifest.threshold as usize { + anyhow::bail!( + "Transaction envelope carries {} signature(s), fewer than the required threshold \ + of {}. The signature log does not match the signed envelope.", + envelope_signatures, + file.manifest.threshold + ); + } + + Ok(file.transaction_envelope_xdr.clone()) +} + +pub fn load_ceremony_file(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("Failed to read ceremony file {}", path.display()))?; + let file: CeremonyFile = serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse ceremony file {}", path.display()))?; + Ok(file) +} + +pub fn save_ceremony_file(path: &Path, file: &CeremonyFile) -> Result<()> { + let json = serde_json::to_string_pretty(file).context("Failed to serialize ceremony file")?; + fs::write(path, format!("{}\n", json)) + .with_context(|| format!("Failed to write ceremony file {}", path.display()))?; + Ok(()) +} + +fn manifest_fingerprint(manifest: &CeremonyManifest) -> String { + let canonical = format!( + "{}|{}|{}|{}|{}|{}|{}", + manifest.source_account, + manifest.network, + manifest.threshold, + manifest.required_signers.join(","), + manifest.operation_summary, + manifest.expires_at.as_deref().unwrap_or(""), + manifest.tx_body_hash, + ); + hex::encode(Sha256::digest(canonical.as_bytes())) +} + +fn hash_tx_body(envelope: &TransactionEnvelope) -> Result { + let bytes = multisig::transaction_body_bytes(envelope)?; + Ok(hex::encode(Sha256::digest(&bytes))) +} + +fn format_unix_timestamp(unix_seconds: i64) -> String { + DateTime::::from_timestamp(unix_seconds, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| unix_seconds.to_string()) +} + +fn describe_operation(operation: &BatchOperation) -> String { + match operation { + BatchOperation::Payment { to, amount, asset } => { + format!("payment {} {} → {}", amount, asset, to) + } + } +} + +fn build_transaction_envelope( + source_account: &str, + operation: &BatchOperation, + sequence: &str, + expires_at_unix: Option, +) -> Result { + let source = MuxedAccount::from_str(source_account) + .map_err(|_| anyhow::anyhow!("Invalid source account: {}", source_account))?; + let seq = sequence + .parse::() + .with_context(|| format!("Invalid sequence number: {}", sequence))? + .checked_add(1) + .context("Source account sequence overflow")?; + + let op = build_operation(operation)?; + let cond = match expires_at_unix { + Some(t) => Preconditions::Time(TimeBounds { + min_time: TimePoint(0), + max_time: TimePoint(t.max(0) as u64), + }), + None => Preconditions::None, + }; + + let tx = Transaction { + source_account: source, + fee: 100, + seq_num: SequenceNumber(seq), + cond, + memo: Memo::None, + operations: vec![op] + .try_into() + .map_err(|_| anyhow::anyhow!("Failed to build operation list"))?, + ext: TransactionExt::V0, + }; + + Ok(TransactionEnvelope::from(tx)) +} + +fn build_operation(operation: &BatchOperation) -> Result { + match operation { + BatchOperation::Payment { to, amount, asset } => { + let destination = MuxedAccount::from_str(to) + .map_err(|_| anyhow::anyhow!("Invalid destination account: {}", to))?; + let (code, issuer) = parse_asset_spec(asset)?; + let asset = build_asset(code.as_deref(), issuer.as_deref())?; + let amount = parse_amount_to_stroops(amount)?; + Ok(Operation { + source_account: None, + body: OperationBody::Payment(PaymentOp { + destination, + asset, + amount, + }), + }) + } + } +} + +fn parse_asset_spec(asset: &str) -> Result<(Option, Option)> { + if asset.eq_ignore_ascii_case("xlm") { + return Ok((None, None)); + } + let parts: Vec<&str> = asset.split(':').collect(); + if parts.len() == 2 && !parts[0].is_empty() { + return Ok((Some(parts[0].to_string()), Some(parts[1].to_string()))); + } + anyhow::bail!("Invalid asset format '{}'. Use XLM or CODE:ISSUER", asset) +} + +fn build_asset(code: Option<&str>, issuer: Option<&str>) -> Result { + match (code, issuer) { + (None, None) => Ok(Asset::Native), + (Some(code), Some(issuer)) => { + let issuer_id = AccountId::from_str(issuer) + .map_err(|_| anyhow::anyhow!("Invalid asset issuer: {}", issuer))?; + if code.is_empty() || code.len() > 12 { + anyhow::bail!("Asset code must be 1-12 characters"); + } + if code.len() <= 4 { + Ok(Asset::CreditAlphanum4(AlphaNum4 { + asset_code: AssetCode4::from_str(code) + .map_err(|_| anyhow::anyhow!("Invalid asset code: {}", code))?, + issuer: issuer_id, + })) + } else { + Ok(Asset::CreditAlphanum12(AlphaNum12 { + asset_code: AssetCode12::from_str(code) + .map_err(|_| anyhow::anyhow!("Invalid asset code: {}", code))?, + issuer: issuer_id, + })) + } + } + _ => anyhow::bail!("Invalid asset specification"), + } +} + +fn parse_amount_to_stroops(amount: &str) -> Result { + let amount = amount.trim(); + if amount.starts_with('-') { + anyhow::bail!("Amount '{}' must not be negative", amount); + } + let (whole, frac) = match amount.split_once('.') { + Some((whole, frac)) => (whole, frac), + None => (amount, ""), + }; + if frac.len() > 7 { + anyhow::bail!("Amount '{}' has more than 7 decimal places", amount); + } + + let whole_val: i64 = if whole.is_empty() { + 0 + } else { + whole + .parse() + .with_context(|| format!("Invalid amount: {}", amount))? + }; + + let mut frac_digits = frac.to_string(); + while frac_digits.len() < 7 { + frac_digits.push('0'); + } + let frac_val: i64 = if frac_digits.is_empty() { + 0 + } else { + frac_digits + .parse() + .with_context(|| format!("Invalid amount: {}", amount))? + }; + + whole_val + .checked_mul(10_000_000) + .and_then(|w| w.checked_add(frac_val)) + .ok_or_else(|| anyhow::anyhow!("Amount '{}' overflows i64 stroops", amount)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::RngCore; + use stellar_strkey::ed25519::PrivateKey as StellarPrivateKey; + + fn test_keypair() -> (String, String) { + let mut seed = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let public_key = StellarPublicKey(signing_key.verifying_key().to_bytes()).to_string(); + let secret_key = StellarPrivateKey(seed).to_string(); + (public_key, secret_key) + } + + fn sample_ceremony(signers: &[(String, String)], threshold: u8) -> CeremonyFile { + let (source_public, _source_secret) = test_keypair(); + let (dest_public, _) = test_keypair(); + let op = BatchOperation::Payment { + to: dest_public, + amount: "100".to_string(), + asset: "XLM".to_string(), + }; + let required: Vec = signers.iter().map(|(pk, _)| pk.clone()).collect(); + build_ceremony( + &source_public, + &op, + "100", + threshold, + required, + "testnet", + Some(60), + ) + .unwrap() + } + + #[test] + fn round_trips_through_json() { + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let file = sample_ceremony(&signers, 2); + + let json = serde_json::to_string_pretty(&file).unwrap(); + let reloaded: CeremonyFile = serde_json::from_str(&json).unwrap(); + + assert_eq!(reloaded.manifest, file.manifest); + assert_eq!( + reloaded.transaction_envelope_xdr, + file.transaction_envelope_xdr + ); + verify_integrity(&reloaded).unwrap(); + } + + #[test] + fn threshold_exactly_met_marks_ready() { + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let mut file = sample_ceremony(&signers, 3); + + for (_, secret) in &signers { + add_signature(&mut file, Some(secret), None).unwrap(); + } + + let report = status(&file).unwrap(); + assert_eq!(report.collected, 3); + assert!(report.threshold_met); + assert!(report.outstanding_signers.is_empty()); + assert!(assemble_for_submit(&file).is_ok()); + } + + #[test] + fn below_threshold_is_rejected_at_submit() { + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let mut file = sample_ceremony(&signers, 3); + + // Only two of the required three signers sign. + add_signature(&mut file, Some(&signers[0].1), None).unwrap(); + add_signature(&mut file, Some(&signers[1].1), None).unwrap(); + + let report = status(&file).unwrap(); + assert_eq!(report.collected, 2); + assert!(!report.threshold_met); + assert_eq!(report.outstanding_signers, vec![signers[2].0.clone()]); + + let err = assemble_for_submit(&file).unwrap_err(); + assert!(err.to_string().contains("Not enough signatures")); + } + + #[test] + fn independent_sign_invocations_reach_threshold_like_sequential_ones() { + // Simulate three separate machines each loading the same on-disk + // ceremony file independently and signing once, with no shared + // process state between invocations. + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let file = sample_ceremony(&signers, 3); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tx.ceremony"); + save_ceremony_file(&path, &file).unwrap(); + + for (_, secret) in &signers { + let mut loaded = load_ceremony_file(&path).unwrap(); + add_signature(&mut loaded, Some(secret), None).unwrap(); + save_ceremony_file(&path, &loaded).unwrap(); + } + + let final_file = load_ceremony_file(&path).unwrap(); + let report = status(&final_file).unwrap(); + assert!(report.threshold_met); + assert_eq!(report.collected, 3); + assemble_for_submit(&final_file).unwrap(); + } + + #[test] + fn duplicate_signer_does_not_double_count() { + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let mut file = sample_ceremony(&signers, 2); + + let first = add_signature(&mut file, Some(&signers[0].1), None).unwrap(); + assert!(!first.already_signed); + + let second = add_signature(&mut file, Some(&signers[0].1), None).unwrap(); + assert!(second.already_signed); + + let report = status(&file).unwrap(); + assert_eq!(report.collected, 1); + assert_eq!(file.signature_log.len(), 1); + } + + #[test] + fn unauthorized_signer_is_rejected() { + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let mut file = sample_ceremony(&signers, 2); + let (_outsider_public, outsider_secret) = test_keypair(); + + let err = add_signature(&mut file, Some(&outsider_secret), None).unwrap_err(); + assert!(err.to_string().contains("not among the required signers")); + } + + #[test] + fn tampered_transaction_body_is_rejected() { + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let mut file = sample_ceremony(&signers, 2); + add_signature(&mut file, Some(&signers[0].1), None).unwrap(); + + // Hand-edit the envelope to point at a different destination without + // updating the stored tx_body_hash — simulates tampering after the + // first signature was collected. + let (other_dest, _) = test_keypair(); + let other_op = BatchOperation::Payment { + to: other_dest, + amount: "100".to_string(), + asset: "XLM".to_string(), + }; + let tampered_envelope = + build_transaction_envelope(&file.manifest.source_account, &other_op, "100", Some(60)) + .unwrap(); + file.transaction_envelope_xdr = + multisig::encode_transaction_envelope(&tampered_envelope).unwrap(); + + let err = verify_integrity(&file).unwrap_err(); + assert!(err + .to_string() + .contains("Transaction integrity check failed")); + + let sign_err = add_signature(&mut file, Some(&signers[1].1), None).unwrap_err(); + assert!(sign_err + .to_string() + .contains("Transaction integrity check failed")); + + let submit_err = assemble_for_submit(&file).unwrap_err(); + assert!(submit_err + .to_string() + .contains("Transaction integrity check failed")); + } + + #[test] + fn tampered_manifest_threshold_is_rejected() { + let signers = vec![test_keypair(), test_keypair(), test_keypair()]; + let mut file = sample_ceremony(&signers, 3); + add_signature(&mut file, Some(&signers[0].1), None).unwrap(); + + // Lower the threshold after the fact without recomputing the hash — + // simulates someone hand-editing the JSON to force an early submit. + file.manifest.threshold = 1; + + let err = verify_integrity(&file).unwrap_err(); + assert!(err + .to_string() + .contains("Ceremony manifest integrity check failed")); + assert!(assemble_for_submit(&file).is_err()); + } + + #[test] + fn expired_ceremony_is_rejected_at_submit() { + let signers = vec![test_keypair(), test_keypair()]; + let mut file = sample_ceremony(&signers, 2); + for (_, secret) in &signers { + add_signature(&mut file, Some(secret), None).unwrap(); + } + + // Force expiry into the past and re-sign the manifest hash so this + // exercises the expiry check specifically, not tamper detection. + file.manifest.expires_at = Some("2000-01-01T00:00:00+00:00".to_string()); + file.manifest.manifest_hash = manifest_fingerprint(&file.manifest); + + let report = status(&file).unwrap(); + assert!(report.expired); + + let err = assemble_for_submit(&file).unwrap_err(); + assert!(err.to_string().contains("Ceremony expired")); + } + + #[test] + fn threshold_cannot_exceed_signer_count() { + let (source_public, _) = test_keypair(); + let (dest_public, _) = test_keypair(); + let op = BatchOperation::Payment { + to: dest_public, + amount: "10".to_string(), + asset: "XLM".to_string(), + }; + let (only_signer, _) = test_keypair(); + + let err = build_ceremony( + &source_public, + &op, + "1", + 2, + vec![only_signer], + "testnet", + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("exceeds the number")); + } + + #[test] + fn parses_and_rejects_amounts() { + assert_eq!(parse_amount_to_stroops("100").unwrap(), 1_000_000_000); + assert_eq!(parse_amount_to_stroops("1.5").unwrap(), 15_000_000); + assert_eq!(parse_amount_to_stroops("0.0000001").unwrap(), 1); + assert!(parse_amount_to_stroops("-1").is_err()); + assert!(parse_amount_to_stroops("1.00000001").is_err()); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 6665933..dd7fa6a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod ai_telemetry; pub mod bindings; +pub mod ceremony; pub mod config; pub mod confirmation; pub mod crypto; diff --git a/src/utils/multisig.rs b/src/utils/multisig.rs index 0d2e8c7..fe9913c 100644 --- a/src/utils/multisig.rs +++ b/src/utils/multisig.rs @@ -398,13 +398,13 @@ fn next_sequence_number(source_sequence: &str) -> Result { .context("Source account sequence overflow") } -fn signing_key_from_secret(secret_key: &str) -> Result { +pub(crate) fn signing_key_from_secret(secret_key: &str) -> Result { let decoded = StellarPrivateKey::from_string(secret_key) .context("Failed to parse Stellar secret key for signing")?; Ok(SigningKey::from_bytes(&decoded.0)) } -fn decode_transaction_envelope(transaction_xdr: &str) -> Result { +pub(crate) fn decode_transaction_envelope(transaction_xdr: &str) -> Result { let xdr_bytes = BASE64 .decode(transaction_xdr.trim()) .context("Failed to decode base64 transaction envelope XDR")?; @@ -412,14 +412,17 @@ fn decode_transaction_envelope(transaction_xdr: &str) -> Result Result { +pub(crate) fn encode_transaction_envelope(envelope: &TransactionEnvelope) -> Result { let xdr_bytes = envelope .to_xdr(Limits::none()) .context("Failed to encode transaction envelope XDR")?; Ok(BASE64.encode(xdr_bytes)) } -fn transaction_signature_hash(envelope: &TransactionEnvelope, network: &str) -> Result<[u8; 32]> { +pub(crate) fn transaction_signature_hash( + envelope: &TransactionEnvelope, + network: &str, +) -> Result<[u8; 32]> { use sha2::{Digest, Sha256}; let network_passphrase = config::get_network_passphrase(network); @@ -446,7 +449,30 @@ fn transaction_signature_hash(envelope: &TransactionEnvelope, network: &str) -> Ok(Sha256::digest(&payload_xdr).into()) } -fn envelope_has_signature_hint(envelope: &TransactionEnvelope, hint: &SignatureHint) -> bool { +/// XDR bytes of the transaction body alone (no signatures). Stable across +/// signing rounds, so hashing this lets independent signers detect whether +/// anyone altered the operations/source/sequence/preconditions after the +/// ceremony began. +pub(crate) fn transaction_body_bytes(envelope: &TransactionEnvelope) -> Result> { + match envelope { + TransactionEnvelope::Tx(tx_v1) => tx_v1 + .tx + .to_xdr(Limits::none()) + .context("Failed to encode transaction body XDR"), + TransactionEnvelope::TxFeeBump(fee_bump) => fee_bump + .tx + .to_xdr(Limits::none()) + .context("Failed to encode fee-bump transaction body XDR"), + TransactionEnvelope::TxV0(_) => { + anyhow::bail!("TransactionEnvelope::TxV0 is not supported") + } + } +} + +pub(crate) fn envelope_has_signature_hint( + envelope: &TransactionEnvelope, + hint: &SignatureHint, +) -> bool { match envelope { TransactionEnvelope::TxV0(tx_v0) => tx_v0.signatures.iter().any(|sig| sig.hint.0 == hint.0), TransactionEnvelope::Tx(tx_v1) => tx_v1.signatures.iter().any(|sig| sig.hint.0 == hint.0), @@ -456,7 +482,7 @@ fn envelope_has_signature_hint(envelope: &TransactionEnvelope, hint: &SignatureH } } -fn append_decorated_signature( +pub(crate) fn append_decorated_signature( envelope: &mut TransactionEnvelope, signature: DecoratedSignature, ) -> Result<()> {