diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d530245 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +target +**/target +**/config.toml diff --git a/.gitignore b/.gitignore index 3413ebb..042f13f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target /core/target *.dev.* +/config.toml +/service/config.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bebccd..12bc0a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,9 @@ If this is just a bug report or a feature request, please open an issue first so ## Running the Service Locally +First follow the service's [key-provisioning instructions](service/README.md#starting-the-service). +The repository no longer supplies an active configuration or a usable signing key. + ```bash cd service cargo run --quiet -- start @@ -60,10 +63,15 @@ The multi-stage `Dockerfile` builds the `service` binary. To build and run: ```bash docker build -t simplicity-unchained-service . -docker run -p 8080:8080 simplicity-unchained-service +docker run --rm -p 30431:30431 \ + --mount type=bind,src=/absolute/path/to/service-config.toml,dst=/app/config.toml,readonly \ + simplicity-unchained-service ``` -The container runs as a non-root user (`uid=1000`). Ensure mounted config files are readable by that user. +Provision the host configuration outside the Docker build context with a unique +signing key; see the service [Docker instructions](service/README.md#docker). +The container runs as a non-root user (`uid=1000`). Ensure the private mounted +configuration is readable by that user. An unconfigured container exits unsuccessfully. ## Reporting Bugs diff --git a/Cargo.lock b/Cargo.lock index 9ea703b..ca87534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1507,13 +1507,16 @@ dependencies = [ "clap", "core", "hex", + "http-body 1.0.1", "log", "serde", "serde_json", "simplicity-lang", + "tempfile", "thiserror", "tokio", "toml", + "tower", "tracing", "tracing-subscriber", "validator", diff --git a/Dockerfile b/Dockerfile index e53d303..0800c54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,15 +13,18 @@ RUN apt-get update && apt-get install -y \ WORKDIR /build # Copy workspace manifests -COPY Cargo.toml rust-toolchain.toml ./ +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ # Copy all workspace members (required by Cargo workspace) COPY cli ./cli COPY core ./core -COPY service ./service +COPY service/Cargo.toml ./service/Cargo.toml +COPY service/src ./service/src +COPY jet_plugin_abi ./jet_plugin_abi +COPY plugin_tests ./plugin_tests # Build the service in release mode -RUN cargo build --release --package service +RUN cargo build --locked --release --package service # Stage 2: Runtime FROM debian:bookworm-slim @@ -41,20 +44,17 @@ WORKDIR /app # Copy the compiled binary from builder COPY --from=builder /build/target/release/service /app/service -# Copy the configuration file -COPY service/config.toml /app/config.toml - # Change ownership to the service user RUN chown -R service:service /app # Switch to non-root user USER service -# Expose the service port (default: 8080) -EXPOSE 8080 +# Expose the example configuration's service port +EXPOSE 30431 # Set the entrypoint ENTRYPOINT ["/app/service"] -# Default command arguments +# Requires an operator-provided read-only configuration mount CMD ["start", "--config", "/app/config.toml"] diff --git a/cli/README.md b/cli/README.md index a7bc18b..c5ac1b0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -21,6 +21,14 @@ Commands: - `address multisig` — Generate a P2TR 2-of-2 multisig address from two public keys and a user leaf hash. +Pass the updated service `/tweak` response's `tweaked_public_key_hex` as +`--pubkey1`, not the untweaked service key or an old CMR-only tweaked key. +When using script-reading jets, include the intended `redeem_script_hex` in the +`/tweak` request before funding and supply the identical bytes to `/sign/psbt` +or `/sign/pset`. Use that same tweaked key for `--cosigner-pubkey` when signing +or spending the recovery leaf. The demo scripts omit the script in both +requests, consistently committing to an empty script. + ### keypair - `keypair generate` — Generate a new secp256k1 keypair. @@ -37,15 +45,53 @@ Commands: ### btc-tx (Bitcoin) - `btc-tx create` — Create a PSBT from UTXOs. +- `btc-tx approval-hash` — Compute the digest an approver must sign for a Bitcoin `tx_approval_hash` policy. - `btc-tx sign` — Sign a PSBT with one secret key (for local co-signing). - `btc-tx finalize` — Finalize a PSBT into a broadcastable transaction. - `btc-tx build-csv-leaf` — Build a CSV recovery leaf script from a user pubkey and timelock. - `btc-tx spend-user-leaf` — Spend the user leaf (Leaf 1) of a P2TR output independently. - `btc-tx finalize-user-leaf` — Finalize a user leaf PSBT into a broadcastable transaction. +### Bitcoin Transaction Approvals + +Use a program that explicitly verifies the approver's BIP-340 signature against +`jet::tx_approval_hash()`. Build it with the updated core `CoreExtension` jet table. +Choose the approver public key and script data before generating and funding the +address. + +For each spend, populate `witness_utxo` for **every** PSBT input, independently +verify the prevouts, review the transaction, and calculate its approval digest: + +```bash +cargo run --quiet -- btc-tx approval-hash \ + --psbt "$PSBT_HEX" \ + --input-index 0 \ + --program "$PROGRAM" \ + --redeem-script-hex "$REDEEM_SCRIPT_HEX" \ + --network testnet4 +``` + +Run this from the `cli` directory. Omit `--redeem-script-hex` for an empty script. +`--network` is required and must match the service's configured `bitcoin_network` +(`bitcoin`, `testnet`, or `testnet4`), even for a local test deployment. + +The JSON response contains `approval_hash_hex` and `cmr_hex`, both 64-character +hex strings. Have the approver sign the **32 decoded approval-hash bytes**, not +the ASCII hex text, using BIP-340. Encode the signature into the program's witness +and submit the same PSBT, input, program and script to `/sign/psbt`. Recompute and +obtain a fresh approval after changing the transaction or prevouts. + +The approval digest is not the co-signer's Taproot sighash. See the +[versioned digest definition](../core/README.md#bitcoin-per-spend-approvals). +Static hashlocks and fixed-message signature policies retain their intentionally +replayable behavior; existing funded static policies are not upgraded by this +command. + ## Demo -First, ensure the Simplicity Unchained service is running: +First, provision a private configuration and unique signing key following the +service's [startup instructions](../service/README.md#starting-the-service). +Then ensure the Simplicity Unchained service is running: ```bash cd service diff --git a/cli/assets/check_opcode.simf b/cli/assets/check_opcode.simf index d6017cf..6530bf3 100644 --- a/cli/assets/check_opcode.simf +++ b/cli/assets/check_opcode.simf @@ -1,4 +1,5 @@ fn main() { + // Commit to the intended script via /tweak before funding, and use the same script when signing. // Test that the opcode extraction works correctly // // You can read any opcode from a script and build an additional branching logic on it. diff --git a/cli/assets/sig_verify_with_pubkey_from_script.simf b/cli/assets/sig_verify_with_pubkey_from_script.simf index e348cbd..39397b8 100644 --- a/cli/assets/sig_verify_with_pubkey_from_script.simf +++ b/cli/assets/sig_verify_with_pubkey_from_script.simf @@ -2,6 +2,7 @@ // // You can utilize pubkeys embedded in scripts to verify signatures against them. // Or do other cryptographic operations that require pubkeys. +// Commit to the intended script via /tweak before funding, and use the same script when signing. fn main() { let signature: [u8; 64] = 0x3d5e45a7e5e98964e523b19f460534ad68c2ace2056d6891fc2851bc88eb4a65ce0f7e69fb47fa2105d073df7619a2eaedff9c2685a5862c8c88eb13aa174bc2; diff --git a/cli/scripts/demo_elements.sh b/cli/scripts/demo_elements.sh index 616f8a0..1c0b066 100755 --- a/cli/scripts/demo_elements.sh +++ b/cli/scripts/demo_elements.sh @@ -143,7 +143,7 @@ echo "User signature added" echo echo "==== Step 8: Finalize ====" -FINALIZE_DATA=$(cargo run --quiet -- tx finalize --pset "$PSET_SIGNED2") +FINALIZE_DATA=$(cargo run --quiet -- tx finalize --pset "$PSET_SIGNED2" --network "$NETWORK") FINAL_TX_HEX=$(echo "$FINALIZE_DATA" | jq -r '.transaction_hex') echo "TXID: $(echo "$FINALIZE_DATA" | jq -r '.txid')" echo diff --git a/cli/src/commands/btc_tx/approval_hash.rs b/cli/src/commands/btc_tx/approval_hash.rs new file mode 100644 index 0000000..ff7dad5 --- /dev/null +++ b/cli/src/commands/btc_tx/approval_hash.rs @@ -0,0 +1,33 @@ +use std::str::FromStr; + +use anyhow::{Context, Result}; +use serde_json::json; +use simplicity::{bitcoin::psbt::Psbt, elements::Script}; +use simplicity_unchained_core::{ + BitcoinNetwork, jets::bitcoin::CoreExtension, program::Program, + runner::bitcoin_execution_environment, +}; + +pub fn execute( + psbt_hex: &str, + input_index: usize, + program: &str, + redeem_script_hex: Option<&str>, + network: &str, +) -> Result<()> { + let psbt = Psbt::deserialize(&hex::decode(psbt_hex).context("Failed to decode PSBT hex")?) + .context("Failed to deserialize PSBT")?; + let program = Program::::from_str(program, None).context("Invalid program")?; + let script = Script::from( + hex::decode(redeem_script_hex.unwrap_or("")).context("Invalid redeem script hex")?, + ); + let network = BitcoinNetwork::from_str(network).map_err(anyhow::Error::msg)?; + let env = bitcoin_execution_environment(&psbt, input_index, program.cmr(), &script, network) + .context("Failed to construct approval context")?; + let output = json!({ + "approval_hash_hex": hex::encode(env.approval_hash), + "cmr_hex": hex::encode(program.cmr().to_byte_array()), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} diff --git a/cli/src/commands/btc_tx/finalize.rs b/cli/src/commands/btc_tx/finalize.rs index ee1fb5d..bdfff25 100644 --- a/cli/src/commands/btc_tx/finalize.rs +++ b/cli/src/commands/btc_tx/finalize.rs @@ -1,7 +1,13 @@ -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result, anyhow, ensure}; use serde_json::json; use simplicity::bitcoin::{ - self, Psbt, TapLeafHash, Transaction, Witness, consensus::serialize, psbt, taproot::LeafVersion, + Psbt, TapLeafHash, Transaction, TxOut, Witness, XOnlyPublicKey, + consensus::serialize, + hashes::Hash, + psbt, + secp256k1::{Message, Secp256k1}, + sighash::{Prevouts, SighashCache}, + taproot::LeafVersion, }; use simplicity_unchained_core::utils::extract_pubkeys_from_p2tr_multisig_leaf_btc; @@ -9,23 +15,7 @@ pub fn execute(psbt_hex: &str) -> Result<()> { let psbt_bytes = hex::decode(psbt_hex).context("Failed to decode PSBT hex")?; let psbt: Psbt = Psbt::deserialize(&psbt_bytes).context("Failed to deserialize PSBT")?; - let mut tx = psbt - .clone() - .extract_tx() - .context("Failed to extract transaction from PSBT")?; - - // For each input, build the witness from partial signatures - for (i, input) in psbt.inputs.iter().enumerate() { - finalize_p2tr(&mut tx, i, input)?; - } - - let all_finalized = tx.input.iter().all(|input| !input.witness.is_empty()); - - if !all_finalized { - return Err(anyhow!( - "Not all inputs are finalized - transaction may be missing signatures" - )); - } + let tx = finalize(&psbt)?; let witnesses: Vec<_> = tx .input @@ -54,44 +44,109 @@ pub fn execute(psbt_hex: &str) -> Result<()> { Ok(()) } -fn finalize_p2tr(tx: &mut Transaction, i: usize, input: &psbt::Input) -> Result<()> { - let (control_block, (leaf_script, _)) = input - .tap_scripts +fn finalize(psbt: &Psbt) -> Result { + let mut tx = psbt.unsigned_tx.clone(); + ensure!(!tx.input.is_empty(), "Transaction has no inputs"); + ensure!( + tx.input.len() == psbt.inputs.len(), + "PSBT input count does not match transaction" + ); + let prevouts = psbt + .inputs .iter() - .next() - .map(|(cb, s)| (cb.clone(), s.clone())) - .ok_or_else(|| anyhow!("Input {} missing tap_scripts", i))?; - - let leaf_hash = TapLeafHash::from_script(&leaf_script, LeafVersion::TapScript); - - let mut witness = Witness::new(); - - let pubkeys = extract_pubkeys_from_p2tr_multisig_leaf_btc(&leaf_script, i) - .map_err(|e| anyhow!("Input {} leaf script is not a multisig leaf: {}", i, e))?; + .enumerate() + .map(|(i, input)| { + input + .witness_utxo + .clone() + .ok_or_else(|| anyhow!("Input {} missing witness_utxo", i)) + }) + .collect::>>()?; + for (i, input) in psbt.inputs.iter().enumerate() { + tx.input[i].witness = finalize_p2tr(&tx, i, input, &prevouts)?; + } + Ok(tx) +} - let mut sigs: Vec> = vec![None; pubkeys.len()]; - for ((xonly_pk, lh), tap_sig) in &input.tap_script_sigs { - if *lh != leaf_hash { +fn finalize_p2tr( + tx: &Transaction, + i: usize, + input: &psbt::Input, + prevouts: &[TxOut], +) -> Result { + let spent_script = &prevouts[i].script_pubkey; + ensure!( + spent_script.is_p2tr(), + "Input {} spent output is not P2TR", + i + ); + ensure!( + tx.input[i].script_sig.is_empty(), + "Input {} native P2TR script_sig must be empty", + i + ); + let output_key = XOnlyPublicKey::from_slice(&spent_script.as_bytes()[2..]) + .with_context(|| format!("Input {} invalid Taproot output key", i))?; + let secp = Secp256k1::verification_only(); + let mut candidate = None; + for (control_block, (leaf_script, leaf_version)) in &input.tap_scripts { + if *leaf_version != LeafVersion::TapScript + || control_block.leaf_version != *leaf_version + || !control_block.verify_taproot_commitment(&secp, output_key, leaf_script) + { continue; } - for (idx, pk) in pubkeys.iter().enumerate() { - if xonly_pk == pk { - sigs[idx] = Some(*tap_sig); - break; - } - } + let Ok(pubkeys) = extract_pubkeys_from_p2tr_multisig_leaf_btc(leaf_script, i) else { + continue; + }; + ensure!( + candidate.is_none(), + "Input {} has ambiguous committed multisig leaves", + i + ); + candidate = Some((control_block, leaf_script, pubkeys)); + } + let (control_block, leaf_script, pubkeys) = + candidate.ok_or_else(|| anyhow!("Input {} has no committed canonical multisig leaf", i))?; + let leaf_hash = TapLeafHash::from_script(leaf_script, LeafVersion::TapScript); + + let signature = |position| { + input + .tap_script_sigs + .get(&(pubkeys[position], leaf_hash)) + .ok_or_else(|| { + anyhow!( + "Input {} missing tap_script_sig for multisig key {}", + i, + position + ) + }) + }; + let cosigner_sig = signature(0)?; + let user_sig = signature(1)?; + let mut cache = SighashCache::new(tx); + for (key, signature) in pubkeys.iter().zip([cosigner_sig, user_sig]) { + let sighash = cache + .taproot_script_spend_signature_hash( + i, + &Prevouts::All(prevouts), + leaf_hash, + signature.sighash_type, + ) + .with_context(|| format!("Input {} failed to compute signature sighash", i))?; + secp.verify_schnorr( + &signature.signature, + &Message::from_digest(sighash.to_byte_array()), + key, + ) + .with_context(|| format!("Input {} invalid tap_script_sig for {}", i, key))?; } - let cosigner_sig = - sigs[0].ok_or_else(|| anyhow!("Input {} missing cosigner tap_script_sig", i))?; - let user_sig = sigs[1].ok_or_else(|| anyhow!("Input {} missing user tap_script_sig", i))?; - + let mut witness = Witness::new(); witness.push(user_sig.serialize()); witness.push(cosigner_sig.serialize()); witness.push(leaf_script.as_bytes()); witness.push(control_block.serialize()); - tx.input[i].witness = witness; - - Ok(()) + Ok(witness) } diff --git a/cli/src/commands/btc_tx/mod.rs b/cli/src/commands/btc_tx/mod.rs index e3ddd95..13564ab 100644 --- a/cli/src/commands/btc_tx/mod.rs +++ b/cli/src/commands/btc_tx/mod.rs @@ -1,3 +1,4 @@ +pub mod approval_hash; pub mod create; pub mod finalize; pub mod sign; diff --git a/cli/src/commands/btc_tx/spend_user_leaf.rs b/cli/src/commands/btc_tx/spend_user_leaf.rs index e380d1b..7bff8b1 100644 --- a/cli/src/commands/btc_tx/spend_user_leaf.rs +++ b/cli/src/commands/btc_tx/spend_user_leaf.rs @@ -16,6 +16,8 @@ use simplicity::bitcoin::{self, base64}; use simplicity_unchained_core::utils::{UNSPENDABLE_KEY_P2TR, p2tr_multisig_leaf_btc}; use std::str::FromStr; +use crate::demo_script::csv::validate_sequence; + pub fn execute( psbt_hex: &str, secret_key_hex: &str, @@ -61,6 +63,17 @@ pub fn execute( .extract_tx() .context("Failed to extract transaction")?; + let input = tx + .input + .get(input_index) + .ok_or_else(|| anyhow::anyhow!("Input index {} out of bounds", input_index))?; + validate_sequence( + user_leaf_script.as_bytes(), + i64::from(tx.version.0), + input.sequence.0, + input_index, + )?; + let prevouts = psbt .inputs .iter() diff --git a/cli/src/commands/tx/finalize.rs b/cli/src/commands/tx/finalize.rs index dfc6f4a..fd8a24f 100644 --- a/cli/src/commands/tx/finalize.rs +++ b/cli/src/commands/tx/finalize.rs @@ -1,36 +1,28 @@ -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result, anyhow, ensure}; use serde_json::json; -use simplicity::elements::taproot::TapLeafHash; +use simplicity::elements::taproot::{LeafVersion, TapLeafHash}; use simplicity::elements::{ - Transaction, + BlockHash, Transaction, TxOut, encode::{deserialize, serialize}, pset::{self, PartiallySignedTransaction}, + schnorr::TapTweak, + secp256k1_zkp::{Message, Secp256k1, XOnlyPublicKey}, + sighash::{Prevouts, SighashCache}, }; -use simplicity_unchained_core::utils::extract_pubkeys_from_p2tr_multisig_leaf_elements; +use simplicity_unchained_core::{ + ElementsNetwork, utils::extract_pubkeys_from_p2tr_multisig_leaf_elements, +}; +use std::str::FromStr; -pub fn execute(pset_hex: &str) -> Result<()> { +pub fn execute(pset_hex: &str, network: &str) -> Result<()> { let pset_bytes = hex::decode(pset_hex).context("Failed to decode PSET hex")?; let pset: PartiallySignedTransaction = deserialize(&pset_bytes).context("Failed to deserialize PSET")?; - let mut tx = pset - .extract_tx() - .context("Failed to extract transaction from PSET")?; - - for (i, input) in pset.inputs().iter().enumerate() { - finalize_p2tr(&mut tx, i, input)?; - } - - let all_finalized = tx - .input - .iter() - .all(|input| !input.witness.script_witness.is_empty()); - - if !all_finalized { - return Err(anyhow!( - "Not all inputs are finalized - transaction may be missing signatures" - )); - } + let genesis_hash = ElementsNetwork::from_str(network) + .map_err(anyhow::Error::msg)? + .genesis_hash(); + let tx = finalize(&pset, genesis_hash)?; let witnesses: Vec<_> = tx .input @@ -58,44 +50,111 @@ pub fn execute(pset_hex: &str) -> Result<()> { Ok(()) } -fn finalize_p2tr(tx: &mut Transaction, i: usize, input: &pset::Input) -> Result<()> { - let (control_block, (leaf_script, leaf_version)) = input - .tap_scripts +fn finalize(pset: &PartiallySignedTransaction, genesis_hash: BlockHash) -> Result { + let mut tx = pset + .extract_tx() + .context("Failed to extract transaction from PSET")?; + ensure!(!tx.input.is_empty(), "Transaction has no inputs"); + let prevouts = pset + .inputs() .iter() - .next() - .map(|(cb, s)| (cb.clone(), s.clone())) - .ok_or_else(|| anyhow!("Input {} missing tap_scripts", i))?; - - let leaf_hash = TapLeafHash::from_script(&leaf_script, leaf_version); - - let pubkeys = extract_pubkeys_from_p2tr_multisig_leaf_elements(&leaf_script, i) - .map_err(|e| anyhow!("Input {} leaf script is not a multisig leaf: {}", i, e))?; - - let mut sigs = vec![None; pubkeys.len()]; + .enumerate() + .map(|(i, input)| { + input + .witness_utxo + .clone() + .ok_or_else(|| anyhow!("Input {} missing witness_utxo", i)) + }) + .collect::>>()?; + for (i, input) in pset.inputs().iter().enumerate() { + tx.input[i].witness.script_witness = + finalize_p2tr(&tx, i, input, &prevouts, genesis_hash)?.into(); + } + Ok(tx) +} - for ((xonly_pk, lh), tap_sig) in &input.tap_script_sigs { - if *lh != leaf_hash { +fn finalize_p2tr( + tx: &Transaction, + i: usize, + input: &pset::Input, + prevouts: &[TxOut], + genesis_hash: BlockHash, +) -> Result>> { + let spent_script = &prevouts[i].script_pubkey; + ensure!( + spent_script.is_v1_p2tr(), + "Input {} spent output is not P2TR", + i + ); + ensure!( + tx.input[i].script_sig.is_empty(), + "Input {} native P2TR script_sig must be empty", + i + ); + // The witness program already contains the tweaked output key. + let output_key = XOnlyPublicKey::from_slice(&spent_script.as_bytes()[2..]) + .with_context(|| format!("Input {} invalid Taproot output key", i))? + .dangerous_assume_tweaked(); + let secp = Secp256k1::verification_only(); + let mut candidate = None; + for (control_block, (leaf_script, leaf_version)) in &input.tap_scripts { + if *leaf_version != LeafVersion::default() + || control_block.leaf_version != *leaf_version + || !control_block.verify_taproot_commitment(&secp, &output_key, leaf_script) + { continue; } - for (idx, pk) in pubkeys.iter().enumerate() { - if xonly_pk == pk { - sigs[idx] = Some(*tap_sig); - break; - } - } + let Ok(pubkeys) = extract_pubkeys_from_p2tr_multisig_leaf_elements(leaf_script, i) else { + continue; + }; + ensure!( + candidate.is_none(), + "Input {} has ambiguous committed multisig leaves", + i + ); + candidate = Some((control_block, leaf_script, pubkeys)); + } + let (control_block, leaf_script, pubkeys) = + candidate.ok_or_else(|| anyhow!("Input {} has no committed canonical multisig leaf", i))?; + let leaf_hash = TapLeafHash::from_script(leaf_script, LeafVersion::default()); + + let signature = |position| { + input + .tap_script_sigs + .get(&(pubkeys[position], leaf_hash)) + .ok_or_else(|| { + anyhow!( + "Input {} missing tap_script_sig for multisig key {}", + i, + position + ) + }) + }; + let cosigner_sig = signature(0)?; + let user_sig = signature(1)?; + let mut cache = SighashCache::new(tx); + for (key, signature) in pubkeys.iter().zip([cosigner_sig, user_sig]) { + let sighash = cache + .taproot_script_spend_signature_hash( + i, + &Prevouts::All(prevouts), + leaf_hash, + signature.hash_ty, + genesis_hash, + ) + .with_context(|| format!("Input {} failed to compute signature sighash", i))?; + secp.verify_schnorr( + &signature.sig, + &Message::from_digest(sighash.to_byte_array()), + key, + ) + .with_context(|| format!("Input {} invalid tap_script_sig for {}", i, key))?; } - let cosigner_sig = - sigs[0].ok_or_else(|| anyhow!("Input {} missing cosigner tap_script_sig", i))?; - let user_sig = sigs[1].ok_or_else(|| anyhow!("Input {} missing user tap_script_sig", i))?; - - tx.input[i].witness.script_witness = vec![ + Ok(vec![ user_sig.to_vec(), cosigner_sig.to_vec(), leaf_script.to_bytes(), control_block.serialize(), - ] - .into(); - - Ok(()) + ]) } diff --git a/cli/src/commands/tx/spend_user_leaf.rs b/cli/src/commands/tx/spend_user_leaf.rs index 3ee9479..4365dc5 100644 --- a/cli/src/commands/tx/spend_user_leaf.rs +++ b/cli/src/commands/tx/spend_user_leaf.rs @@ -17,6 +17,8 @@ use simplicity_unchained_core::{ }; use std::str::FromStr; +use crate::demo_script::csv::validate_sequence; + pub fn execute( pset_hex: &str, secret_key_hex: &str, @@ -63,6 +65,17 @@ pub fn execute( let tx = pset.extract_tx().context("Failed to extract transaction")?; + let input = tx + .input + .get(input_index) + .ok_or_else(|| anyhow!("Input index {} out of bounds", input_index))?; + validate_sequence( + user_leaf_script.as_bytes(), + i64::from(tx.version), + input.sequence.0, + input_index, + )?; + let prevouts = pset .inputs() .iter() diff --git a/cli/src/demo_script/csv.rs b/cli/src/demo_script/csv.rs new file mode 100644 index 0000000..a49131d --- /dev/null +++ b/cli/src/demo_script/csv.rs @@ -0,0 +1,149 @@ +use anyhow::{Context, Result, anyhow, ensure}; +use simplicity::bitcoin::{ + Script, Sequence, XOnlyPublicKey, + opcodes::all::{OP_CHECKSIG, OP_CSV, OP_DROP}, + relative::LockTime, + script::{Builder, Instruction}, +}; + +pub(crate) fn validate_sequence( + script: &[u8], + version: i64, + sequence: u32, + input_index: usize, +) -> Result<()> { + // Bitcoin and Elements use the same encoding for this recovery script. + let instructions = Script::from_bytes(script) + .instructions() + .collect::, _>>() + .with_context(|| format!("Input {input_index}: failed to parse recovery script"))?; + if !instructions.contains(&Instruction::Op(OP_CSV)) { + return Ok(()); + } + + let unsupported = || { + anyhow!( + "Input {input_index}: unsupported CSV recovery script; expected \ + OP_CSV OP_DROP OP_CHECKSIG" + ) + }; + let [ + delay, + Instruction::Op(OP_CSV), + Instruction::Op(OP_DROP), + Instruction::PushBytes(key), + Instruction::Op(OP_CHECKSIG), + ] = instructions.as_slice() + else { + return Err(unsupported()); + }; + let blocks = delay + .script_num() + .and_then(|value| u16::try_from(value).ok()) + .ok_or_else(unsupported)?; + let key = XOnlyPublicKey::from_slice(key.as_bytes()) + .with_context(|| format!("Input {input_index}: invalid CSV recovery public key"))?; + let canonical = Builder::new() + .push_int(i64::from(blocks)) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&key) + .push_opcode(OP_CHECKSIG) + .into_script(); + ensure!(script == canonical.as_bytes(), "{}", unsupported()); + ensure!( + version >= 2, + "Input {input_index}: CSV recovery requires transaction version >= 2" + ); + ensure!( + LockTime::from_height(blocks).is_implied_by_sequence(Sequence::from_consensus(sequence)), + "Input {input_index}: CSV recovery requires a block-based sequence of at least \ + {blocks} with the disable flag clear (got {sequence:#010x}); \ + set --sequence before signing" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use simplicity::bitcoin::opcodes::all::{OP_ENDIF, OP_IF}; + use simplicity_unchained_core::utils::UNSPENDABLE_KEY_P2TR; + + fn leaf(blocks: i64) -> simplicity::bitcoin::ScriptBuf { + Builder::new() + .push_int(blocks) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&UNSPENDABLE_KEY_P2TR) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + #[test] + fn validates_block_delays_and_bip68_flags() { + for blocks in [0, 1, 16, 17, 127, 128, 144, u16::MAX] { + let script = leaf(i64::from(blocks)); + for sequence in [ + u32::from(blocks), + u32::from(u16::MAX), + 0x2000_0000 | u32::from(blocks), + ] { + validate_sequence(script.as_bytes(), 2, sequence, 1).unwrap(); + validate_sequence(script.as_bytes(), 3, sequence, 1).unwrap(); + } + for sequence in [ + u32::MAX, + 0x8000_0000 | u32::from(blocks), + Sequence::from_512_second_intervals(blocks).0, + ] { + let error = validate_sequence(script.as_bytes(), 2, sequence, 1).unwrap_err(); + assert!( + error + .to_string() + .contains("Input 1: CSV recovery requires a block-based") + ); + } + if blocks > 0 { + assert!(validate_sequence(script.as_bytes(), 2, u32::from(blocks - 1), 0).is_err()); + } + for version in [-1, 0, 1] { + assert!( + validate_sequence(script.as_bytes(), version, u32::from(blocks), 0).is_err() + ); + } + } + } + + #[test] + fn rejects_unsupported_csv_scripts_but_preserves_non_csv_leaves() { + for blocks in [-1, 65_536, 1 << 22, 1 << 31] { + let error = validate_sequence(leaf(blocks).as_bytes(), 2, u32::MAX, 0).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported CSV recovery script") + ); + } + let conditional = Builder::new() + .push_opcode(OP_IF) + .push_int(144) + .push_opcode(OP_CSV) + .push_opcode(OP_ENDIF) + .into_script(); + assert!(validate_sequence(conditional.as_bytes(), 2, 144, 0).is_err()); + let script = leaf(144).into_bytes(); + let mut nonminimal = vec![0x4c, script[0]]; + nonminimal.extend_from_slice(&script[1..]); + assert!(validate_sequence(&nonminimal, 2, 144, 0).is_err()); + let mut nonminimal_number = leaf(0).into_bytes(); + nonminimal_number.splice(..1, [1, 0]); + assert!(validate_sequence(&nonminimal_number, 2, 0, 0).is_err()); + for script in [ + Builder::new().push_int(1).into_script(), + Builder::new().push_slice([OP_CSV.to_u8()]).into_script(), + ] { + validate_sequence(script.as_bytes(), 1, u32::MAX, 0).unwrap(); + } + } +} diff --git a/cli/src/demo_script/csv_leaf_btc.rs b/cli/src/demo_script/csv_leaf_btc.rs index a01890a..0e5023d 100644 --- a/cli/src/demo_script/csv_leaf_btc.rs +++ b/cli/src/demo_script/csv_leaf_btc.rs @@ -10,6 +10,8 @@ use simplicity::{ }; use std::str::FromStr; +use super::csv::validate_sequence; + pub fn build_csv_script(user_pubkey_hex: &str, timelock: u16) -> Result<()> { let user_pubkey = PublicKey::from_str(user_pubkey_hex).context("Failed to parse user pubkey")?; @@ -61,6 +63,13 @@ fn finalize_internal(tx: &mut Transaction, i: usize, input: &psbt::Input) -> Res .map(|(cb, s)| (cb.clone(), s.clone())) .ok_or_else(|| anyhow!("Input {} missing tap_scripts", i))?; + validate_sequence( + leaf_script.as_bytes(), + i64::from(tx.version.0), + tx.input[i].sequence.0, + i, + )?; + let leaf_hash = TapLeafHash::from_script(&leaf_script, LeafVersion::TapScript); let sig = input diff --git a/cli/src/demo_script/csv_leaf_elements.rs b/cli/src/demo_script/csv_leaf_elements.rs index 24cd08e..04050fa 100644 --- a/cli/src/demo_script/csv_leaf_elements.rs +++ b/cli/src/demo_script/csv_leaf_elements.rs @@ -12,6 +12,8 @@ use simplicity::{ }, }; +use super::csv::validate_sequence; + pub fn build_csv_script(user_pubkey_hex: &str, timelock: u16) -> Result<()> { let user_pubkey_bytes = hex::decode(user_pubkey_hex).context("Failed to decode user pubkey hex")?; @@ -62,6 +64,13 @@ fn finalize_internal(tx: &mut elements::Transaction, i: usize, input: &pset::Inp .map(|(cb, s)| (cb.clone(), s.clone())) .ok_or_else(|| anyhow!("Input {} missing tap_scripts", i))?; + validate_sequence( + leaf_script.as_bytes(), + i64::from(tx.version), + tx.input[i].sequence.0, + i, + )?; + let leaf_hash = TapLeafHash::from_script(&leaf_script, leaf_version); let sig = input diff --git a/cli/src/demo_script/mod.rs b/cli/src/demo_script/mod.rs index 5cb6cf8..e1edd04 100644 --- a/cli/src/demo_script/mod.rs +++ b/cli/src/demo_script/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod csv; pub mod csv_leaf_btc; pub mod csv_leaf_elements; diff --git a/cli/src/main.rs b/cli/src/main.rs index 4006fe0..971f51d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -41,10 +41,9 @@ enum Commands { #[derive(Subcommand)] enum AddressCommands { - /// Generate a 2-of-2 multisig address from two public keys. - /// Use --type to select p2sh (legacy) or p2wsh (segwit v0). Defaults to p2wsh. + /// Generate a P2TR 2-of-2 multisig address from two public keys. Multisig { - /// First public key in hex format + /// Script-bound tweaked cosigner public key from the service /tweak endpoint, in hex #[arg(short = '1', long)] pubkey1: String, @@ -70,6 +69,25 @@ enum KeypairCommands { #[derive(Subcommand)] enum BtcTxCommands { + /// Compute the per-spend digest for a tx_approval_hash policy (not a Taproot sighash) + ApprovalHash { + /// PSBT in hex, with witness_utxo populated for every input + #[arg(long)] + psbt: String, + /// Selected input + #[arg(long)] + input_index: usize, + /// Encoded Bitcoin Simplicity program (hex or base64) + #[arg(long)] + program: String, + /// Exact script data committed at address generation + #[arg(long)] + redeem_script_hex: Option, + /// Must match the service's bitcoin_network (bitcoin, testnet, testnet4) + #[arg(long)] + network: String, + }, + /// Create a PSBT (PartiallySignedTransaction) from UTXOs Create { /// Transaction input in format: txid:vout @@ -84,7 +102,7 @@ enum BtcTxCommands { #[arg(short, long, default_value = "bitcoin")] network: String, - /// Relative timelock in blocks for recovery path + /// Relative locktime in blocks for all inputs; CSV recovery requires >= the leaf delay #[arg(long)] sequence: Option, }, @@ -184,7 +202,7 @@ enum TxCommands { #[arg(short, long, default_value = "elements")] network: String, - /// Relative timelock in blocks for recovery path + /// Relative locktime in blocks for all inputs; CSV recovery requires >= the leaf delay #[arg(long)] sequence: Option, }, @@ -220,6 +238,10 @@ enum TxCommands { /// PSET in hex format #[arg(short, long)] pset: String, + + /// Network used for signature verification (liquid, liquid_testnet) + #[arg(short, long, default_value = "liquid_testnet")] + network: String, }, /// Spend the user leaf (Leaf 1) of a P2TR output independently @@ -324,8 +346,8 @@ fn main() -> Result<()> { )?; } - TxCommands::Finalize { pset } => { - commands::tx::finalize::execute(&pset)?; + TxCommands::Finalize { pset, network } => { + commands::tx::finalize::execute(&pset, &network)?; } TxCommands::SpendUserLeaf { psbt: pset, @@ -358,6 +380,22 @@ fn main() -> Result<()> { }, Commands::BtcTx { command } => match command { + BtcTxCommands::ApprovalHash { + psbt, + input_index, + program, + redeem_script_hex, + network, + } => { + commands::btc_tx::approval_hash::execute( + &psbt, + input_index, + &program, + redeem_script_hex.as_deref(), + &network, + )?; + } + BtcTxCommands::Create { inputs, outputs, diff --git a/cli/tests/approval_hash.rs b/cli/tests/approval_hash.rs new file mode 100644 index 0000000..13a5ab4 --- /dev/null +++ b/cli/tests/approval_hash.rs @@ -0,0 +1,73 @@ +use std::process::Command; + +use simplicity::{ + bitcoin::{self, psbt::Psbt}, + elements::Script, +}; +use simplicity_unchained_core::{ + BitcoinNetwork, jets::bitcoin::CoreExtension, program::Program, + runner::bitcoin_execution_environment, +}; + +#[test] +fn approval_hash_command_matches_the_execution_context() { + let program = "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA"; + let tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(50_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(bitcoin::TxOut { + value: bitcoin::Amount::from_sat(100_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }); + let cmr = Program::::from_str(program, None) + .unwrap() + .cmr(); + let expected = bitcoin_execution_environment( + &psbt, + 0, + cmr, + &Script::from(vec![0x52]), + BitcoinNetwork::Testnet4, + ) + .unwrap(); + let invoke = |psbt: &Psbt| { + Command::new(env!("CARGO_BIN_EXE_simplicity-unchained")) + .args([ + "btc-tx", + "approval-hash", + "--psbt", + &hex::encode(psbt.serialize()), + "--input-index", + "0", + "--program", + program, + "--network", + "testnet4", + "--redeem-script-hex", + "52", + ]) + .output() + .unwrap() + }; + let result = invoke(&psbt); + assert!(result.status.success()); + let output: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!( + output, + serde_json::json!({ + "approval_hash_hex": hex::encode(expected.approval_hash), + "cmr_hex": hex::encode(cmr.to_byte_array()), + }) + ); + psbt.inputs[0].witness_utxo = None; + let result = invoke(&psbt); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("witness_utxo")); +} diff --git a/cli/tests/csv_recovery.rs b/cli/tests/csv_recovery.rs new file mode 100644 index 0000000..8c7a772 --- /dev/null +++ b/cli/tests/csv_recovery.rs @@ -0,0 +1,358 @@ +use std::process::{Command, Output}; + +use simplicity::{ + bitcoin::{self as btc, hashes::Hash}, + elements::{ + self as el, + secp256k1_zkp::{Keypair, Message, Secp256k1, SecretKey}, + }, +}; +use simplicity_unchained_core::{ + ElementsNetwork, + utils::{UNSPENDABLE_KEY_P2TR, p2tr_multisig_leaf_btc, p2tr_multisig_leaf_elements}, +}; + +#[derive(Clone, Copy)] +enum Chain { + Bitcoin, + Elements, +} + +impl Chain { + fn command(self) -> &'static str { + match self { + Self::Bitcoin => "btc-tx", + Self::Elements => "tx", + } + } + + fn envelope_key(self) -> &'static str { + match self { + Self::Bitcoin => "psbt", + Self::Elements => "pset", + } + } +} + +const CHAINS: [Chain; 2] = [Chain::Bitcoin, Chain::Elements]; + +fn keys() -> [Keypair; 2] { + [0x11, 0x22].map(|value| { + Keypair::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[value; 32]).unwrap(), + ) + }) +} + +fn invoke(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_simplicity-unchained")) + .args(args) + .output() + .unwrap() +} + +fn success(output: Output) -> serde_json::Value { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +fn assert_failure(output: Output, expected: &str) { + assert!(!output.status.success(), "invalid recovery spend succeeded"); + assert!(output.stdout.is_empty(), "failure emitted success JSON"); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains(expected), "expected {expected}, got {error}"); +} + +fn recovery_leaf(chain: Chain, blocks: u16) -> Vec { + let result = success(invoke(&[ + chain.command(), + "build-csv-leaf", + "--user-pubkey", + &btc::PublicKey::new(keys()[1].public_key()).to_string(), + "--timelock", + &blocks.to_string(), + ])); + hex::decode(result["script"].as_str().unwrap()).unwrap() +} + +fn fixture(chain: Chain, version: u32, sequences: [u32; 2], script: &[u8]) -> String { + let secp = Secp256k1::new(); + let [cosigner_key, user_key] = keys(); + let cosigner = btc::PublicKey::new(cosigner_key.public_key()); + let user = btc::PublicKey::new(user_key.public_key()); + match chain { + Chain::Bitcoin => { + use btc::{ + sighash::{Prevouts, SighashCache}, + taproot::{LeafVersion, TapLeafHash, TaprootBuilder}, + }; + let leaf = btc::ScriptBuf::from_bytes(script.to_vec()); + let spend = TaprootBuilder::new() + .add_leaf(1, p2tr_multisig_leaf_btc(&cosigner, &user)) + .unwrap() + .add_leaf(1, leaf.clone()) + .unwrap() + .finalize(&secp, *UNSPENDABLE_KEY_P2TR) + .unwrap(); + let tx = btc::Transaction { + version: btc::transaction::Version(i32::try_from(version).unwrap()), + lock_time: btc::absolute::LockTime::ZERO, + input: sequences + .into_iter() + .enumerate() + .map(|(index, sequence)| btc::TxIn { + previous_output: btc::OutPoint { + txid: btc::Txid::all_zeros(), + vout: u32::try_from(index).unwrap(), + }, + sequence: btc::Sequence(sequence), + ..btc::TxIn::default() + }) + .collect(), + output: vec![btc::TxOut { + value: btc::Amount::from_sat(190_000), + script_pubkey: btc::ScriptBuf::new(), + }], + }; + let prevouts = vec![ + btc::TxOut { + value: btc::Amount::from_sat(100_000), + script_pubkey: btc::ScriptBuf::new_p2tr_tweaked(spend.output_key()), + }; + 2 + ]; + let mut psbt = btc::Psbt::from_unsigned_tx(tx).unwrap(); + let leaf_hash = TapLeafHash::from_script(&leaf, LeafVersion::TapScript); + let control = spend + .control_block(&(leaf.clone(), LeafVersion::TapScript)) + .unwrap(); + for (index, input) in psbt.inputs.iter_mut().enumerate() { + input.witness_utxo = Some(prevouts[index].clone()); + input + .tap_scripts + .insert(control.clone(), (leaf.clone(), LeafVersion::TapScript)); + let digest = SighashCache::new(&psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + index, + &Prevouts::All(&prevouts), + leaf_hash, + btc::TapSighashType::Default, + ) + .unwrap(); + input.tap_script_sigs.insert( + (user_key.x_only_public_key().0, leaf_hash), + btc::taproot::Signature { + signature: secp + .sign_schnorr(&Message::from_digest(digest.to_byte_array()), &user_key), + sighash_type: btc::TapSighashType::Default, + }, + ); + } + hex::encode(psbt.serialize()) + } + Chain::Elements => { + use el::{ + pset::PartiallySignedTransaction as Pset, + sighash::{Prevouts, SighashCache}, + taproot::{LeafVersion, TapLeafHash, TaprootBuilder}, + }; + let leaf = el::Script::from(script.to_vec()); + let spend = TaprootBuilder::new() + .add_leaf(1, p2tr_multisig_leaf_elements(&cosigner, &user)) + .unwrap() + .add_leaf(1, leaf.clone()) + .unwrap() + .finalize(&secp, *UNSPENDABLE_KEY_P2TR) + .unwrap(); + let tx = el::Transaction { + version, + lock_time: el::LockTime::ZERO, + input: sequences + .into_iter() + .enumerate() + .map(|(index, sequence)| el::TxIn { + previous_output: el::OutPoint { + txid: el::Txid::from_byte_array([0; 32]), + vout: u32::try_from(index).unwrap(), + }, + sequence: el::Sequence(sequence), + ..el::TxIn::default() + }) + .collect(), + output: vec![el::TxOut { + asset: el::confidential::Asset::Explicit(el::AssetId::default()), + value: el::confidential::Value::Explicit(190_000), + ..el::TxOut::default() + }], + }; + let prevouts = vec![ + el::TxOut { + asset: el::confidential::Asset::Explicit(el::AssetId::default()), + value: el::confidential::Value::Explicit(100_000), + script_pubkey: el::Script::new_v1_p2tr_tweaked(spend.output_key()), + ..el::TxOut::default() + }; + 2 + ]; + let mut pset = Pset::from_tx(tx.clone()); + let leaf_hash = TapLeafHash::from_script(&leaf, LeafVersion::default()); + let control = spend + .control_block(&(leaf.clone(), LeafVersion::default())) + .unwrap(); + for (index, input) in pset.inputs_mut().iter_mut().enumerate() { + input.witness_utxo = Some(prevouts[index].clone()); + input + .tap_scripts + .insert(control.clone(), (leaf.clone(), LeafVersion::default())); + let digest = SighashCache::new(&tx) + .taproot_script_spend_signature_hash( + index, + &Prevouts::All(&prevouts), + leaf_hash, + el::SchnorrSighashType::Default, + ElementsNetwork::LiquidTestnet.genesis_hash(), + ) + .unwrap(); + input.tap_script_sigs.insert( + (user_key.x_only_public_key().0, leaf_hash), + el::SchnorrSig { + sig: secp + .sign_schnorr(&Message::from_digest(digest.to_byte_array()), &user_key), + hash_ty: el::SchnorrSighashType::Default, + }, + ); + } + hex::encode(el::encode::serialize(&pset)) + } + } +} + +fn sign(chain: Chain, envelope: &str, script: &[u8], index: usize) -> Output { + invoke(&[ + chain.command(), + "spend-user-leaf", + "--psbt", + envelope, + "--secret-key", + &hex::encode([0x22; 32]), + "--user-leaf-script", + &hex::encode(script), + "--cosigner-pubkey", + &btc::PublicKey::new(keys()[0].public_key()).to_string(), + "--input-index", + &index.to_string(), + ]) +} + +fn finalize(chain: Chain, envelope: &str) -> Output { + invoke(&[chain.command(), "finalize-user-leaf", "--psbt", envelope]) +} + +fn txid(chain: Chain, envelope: &str) -> String { + let bytes = hex::decode(envelope).unwrap(); + match chain { + Chain::Bitcoin => btc::Psbt::deserialize(&bytes) + .unwrap() + .unsigned_tx + .compute_txid() + .to_string(), + Chain::Elements => el::encode::deserialize::(&bytes) + .unwrap() + .extract_tx() + .unwrap() + .txid() + .to_string(), + } +} + +#[test] +fn recovery_commands_reject_incompatible_sequences_on_each_chain() { + for chain in CHAINS { + let script = recovery_leaf(chain, 144); + for sequence in [u32::MAX, 0x8000_0090, 0x0040_0090, 0, 143] { + let envelope = fixture(chain, 2, [144, sequence], &script); + assert_failure( + sign(chain, &envelope, &script, 1), + "Input 1: CSV recovery requires a block-based sequence of at least 144", + ); + assert_failure( + finalize(chain, &envelope), + "Input 1: CSV recovery requires a block-based sequence of at least 144", + ); + success(sign(chain, &envelope, &script, 0)); + } + let default_sequences = fixture(chain, 2, [u32::MAX; 2], &script); + assert_failure( + finalize(chain, &default_sequences), + "Input 0: CSV recovery requires a block-based sequence", + ); + } +} + +#[test] +fn recovery_commands_reject_old_transaction_versions_on_each_chain() { + for chain in CHAINS { + let script = recovery_leaf(chain, 144); + let envelope = fixture(chain, 1, [144; 2], &script); + assert_failure( + sign(chain, &envelope, &script, 0), + "transaction version >= 2", + ); + assert_failure(finalize(chain, &envelope), "transaction version >= 2"); + } +} + +#[test] +fn valid_recovery_delays_preserve_transactions_through_signing_and_finalization() { + for chain in CHAINS { + for blocks in [0, 1, 16, 144, u16::MAX] { + let script = recovery_leaf(chain, blocks); + let envelope = fixture(chain, 2, [u32::from(blocks), u32::from(u16::MAX)], &script); + let expected_txid = txid(chain, &envelope); + let mut signed = envelope; + for index in 0..2 { + let result = success(sign(chain, &signed, &script, index)); + signed = result[chain.envelope_key()].as_str().unwrap().to_string(); + assert_eq!(txid(chain, &signed), expected_txid); + } + let finalized = success(finalize(chain, &signed)); + assert_eq!(finalized["txid"], expected_txid); + assert!(!finalized["transaction_hex"].as_str().unwrap().is_empty()); + } + } +} + +#[test] +fn other_csv_scripts_fail_explicitly_and_non_csv_leaves_remain_supported() { + for chain in CHAINS { + let mut script = recovery_leaf(chain, 144); + script.push(btc::opcodes::all::OP_NOP.to_u8()); + let envelope = fixture(chain, 2, [144; 2], &script); + assert_failure( + sign(chain, &envelope, &script, 0), + "unsupported CSV recovery script", + ); + assert_failure( + finalize(chain, &envelope), + "unsupported CSV recovery script", + ); + + let script = btc::script::Builder::new() + .push_x_only_key(&keys()[1].x_only_public_key().0) + .push_opcode(btc::opcodes::all::OP_CHECKSIG) + .into_script() + .into_bytes(); + let envelope = fixture(chain, 1, [u32::MAX; 2], &script); + let result = success(sign(chain, &envelope, &script, 0)); + let finalized = success(finalize( + chain, + result[chain.envelope_key()].as_str().unwrap(), + )); + assert_eq!(finalized["txid"], txid(chain, &envelope)); + } +} diff --git a/cli/tests/finalize.rs b/cli/tests/finalize.rs new file mode 100644 index 0000000..3fa1a97 --- /dev/null +++ b/cli/tests/finalize.rs @@ -0,0 +1,530 @@ +use std::process::{Command, Output}; + +use simplicity::{ + bitcoin::{self as btc, hashes::Hash}, + elements::{ + self as el, + secp256k1_zkp::{Keypair, Message, Secp256k1, SecretKey}, + }, +}; +use simplicity_unchained_core::{ + ElementsNetwork, + utils::{UNSPENDABLE_KEY_P2TR, p2tr_multisig_leaf_btc, p2tr_multisig_leaf_elements}, +}; + +#[derive(Clone, Copy)] +enum Leaves { + Cooperative, + OpcodeLookalike, + Ambiguous, +} + +fn keypairs() -> [Keypair; 2] { + let secp = Secp256k1::new(); + [0x11, 0x22] + .map(|byte| Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[byte; 32]).unwrap())) +} + +fn public_keys(keys: &[Keypair; 2]) -> [btc::PublicKey; 2] { + keys.map(|key| btc::PublicKey::new(key.public_key())) +} + +fn invoke(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_simplicity-unchained")) + .args(args) + .output() + .unwrap() +} + +fn assert_failure(output: Output, expected: &str) { + assert!( + !output.status.success(), + "invalid envelope was reported finalized" + ); + assert!( + output.stdout.is_empty(), + "failure emitted success-shaped stdout" + ); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains(expected), "expected {expected}, got {error}"); +} + +fn success(output: Output) -> String { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["finalized"], true); + assert_eq!(result["inputs"], 2); + result["transaction_hex"].as_str().unwrap().to_string() +} + +mod bitcoin { + use super::*; + use btc::{ + psbt::Psbt, + sighash::{Prevouts, SighashCache}, + taproot::{LeafVersion, TapLeafHash, TaprootBuilder}, + }; + + fn fixture(leaves: Leaves) -> Psbt { + let secp = Secp256k1::new(); + let keys = keypairs(); + let [cosigner, user] = public_keys(&keys); + let mut leaf = p2tr_multisig_leaf_btc(&cosigner, &user); + if matches!(leaves, Leaves::OpcodeLookalike) { + let mut bytes = leaf.into_bytes(); + bytes[33] = btc::opcodes::all::OP_DROP.to_u8(); + leaf = btc::ScriptBuf::from_bytes(bytes); + } + let other_leaf = if matches!(leaves, Leaves::Ambiguous) { + p2tr_multisig_leaf_btc(&user, &cosigner) + } else { + btc::script::Builder::new().push_int(1).into_script() + }; + let spend_info = TaprootBuilder::new() + .add_leaf(1, leaf.clone()) + .unwrap() + .add_leaf(1, other_leaf.clone()) + .unwrap() + .finalize(&secp, *UNSPENDABLE_KEY_P2TR) + .unwrap(); + let tx = btc::Transaction { + version: btc::transaction::Version::TWO, + lock_time: btc::absolute::LockTime::ZERO, + input: (0..2) + .map(|vout| btc::TxIn { + previous_output: btc::OutPoint { + txid: btc::Txid::all_zeros(), + vout, + }, + ..btc::TxIn::default() + }) + .collect(), + output: vec![btc::TxOut { + value: btc::Amount::from_sat(190_000), + script_pubkey: btc::ScriptBuf::new(), + }], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + let prevouts = vec![ + btc::TxOut { + value: btc::Amount::from_sat(100_000), + script_pubkey: btc::ScriptBuf::new_p2tr_tweaked(spend_info.output_key()), + }; + 2 + ]; + let leaf_hash = TapLeafHash::from_script(&leaf, LeafVersion::TapScript); + let control = spend_info + .control_block(&(leaf.clone(), LeafVersion::TapScript)) + .unwrap(); + for (index, input) in psbt.inputs.iter_mut().enumerate() { + input.witness_utxo = Some(prevouts[index].clone()); + input + .tap_scripts + .insert(control.clone(), (leaf.clone(), LeafVersion::TapScript)); + if matches!(leaves, Leaves::Ambiguous) { + let other_control = spend_info + .control_block(&(other_leaf.clone(), LeafVersion::TapScript)) + .unwrap(); + input + .tap_scripts + .insert(other_control, (other_leaf.clone(), LeafVersion::TapScript)); + } + for (key, sighash_type) in keys + .iter() + .zip([btc::TapSighashType::Default, btc::TapSighashType::All]) + { + let hash = SighashCache::new(&psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + index, + &Prevouts::All(&prevouts), + leaf_hash, + sighash_type, + ) + .unwrap(); + input.tap_script_sigs.insert( + (key.x_only_public_key().0, leaf_hash), + btc::taproot::Signature { + signature: secp + .sign_schnorr(&Message::from_digest(hash.to_byte_array()), key), + sighash_type, + }, + ); + } + } + psbt + } + + fn run(psbt: &Psbt) -> Output { + invoke(&[ + "btc-tx", + "finalize", + "--psbt", + &hex::encode(psbt.serialize()), + ]) + } + + #[test] + fn earlier_decoys_do_not_change_a_verified_cooperative_witness() { + let original = fixture(Leaves::Cooperative); + let expected = success(run(&original)); + for swapped in [false, true] { + let mut psbt = original.clone(); + for input in &mut psbt.inputs { + let (control, (leaf, version)) = input.tap_scripts.first_key_value().unwrap(); + let mut decoy = control.clone(); + decoy.merkle_branch = Default::default(); + let decoy_leaf = if swapped { + let [cosigner, user] = public_keys(&keypairs()); + p2tr_multisig_leaf_btc(&user, &cosigner) + } else { + btc::script::Builder::new().push_int(1).into_script() + }; + assert!(decoy < *control); + assert_ne!(decoy_leaf, *leaf); + let version = *version; + input + .tap_scripts + .insert(decoy.clone(), (decoy_leaf, version)); + assert_eq!(input.tap_scripts.first_key_value().unwrap().0, &decoy); + input.final_script_witness = Some(btc::Witness::from_slice(&[vec![1, 2, 3]])); + } + assert_eq!(success(run(&psbt)), expected); + } + let tx: btc::Transaction = + btc::consensus::deserialize(&hex::decode(expected).unwrap()).unwrap(); + let [cosigner, user] = public_keys(&keypairs()); + let leaf = p2tr_multisig_leaf_btc(&cosigner, &user); + let hash = TapLeafHash::from_script(&leaf, LeafVersion::TapScript); + for (input, finalized) in original.inputs.iter().zip(&tx.input) { + let expected = [ + input.tap_script_sigs[&(user.inner.x_only_public_key().0, hash)] + .serialize() + .to_vec(), + input.tap_script_sigs[&(cosigner.inner.x_only_public_key().0, hash)] + .serialize() + .to_vec(), + leaf.to_bytes(), + input.tap_scripts.first_key_value().unwrap().0.serialize(), + ]; + assert_eq!(finalized.witness.to_vec(), expected); + } + } + + #[test] + fn rejects_committed_opcode_lookalikes_and_ambiguous_leaves() { + assert_failure( + run(&fixture(Leaves::OpcodeLookalike)), + "no committed canonical multisig leaf", + ); + assert_failure( + run(&fixture(Leaves::Ambiguous)), + "ambiguous committed multisig leaves", + ); + } + + #[test] + fn rejects_invalid_control_versions_and_foreign_output() { + let original = fixture(Leaves::Cooperative); + let mut changed = original.clone(); + let (control, value) = changed.inputs[0].tap_scripts.pop_first().unwrap(); + let mut invalid = control.clone(); + invalid.merkle_branch = Default::default(); + changed.inputs[0].tap_scripts.insert(invalid, value.clone()); + assert_failure(run(&changed), "no committed canonical multisig leaf"); + let mut changed = original.clone(); + changed.inputs[0].tap_scripts.insert( + control, + (value.0, LeafVersion::from_consensus(0xc2).unwrap()), + ); + assert_failure(run(&changed), "no committed canonical multisig leaf"); + let mut changed = original.clone(); + changed.inputs[0] + .witness_utxo + .as_mut() + .unwrap() + .script_pubkey = original.unsigned_tx.output[0].script_pubkey.clone(); + assert_failure(run(&changed), "spent output is not P2TR"); + let mut changed = original.clone(); + changed.inputs[0] + .witness_utxo + .as_mut() + .unwrap() + .script_pubkey = + btc::ScriptBuf::new_p2tr(&Secp256k1::new(), keypairs()[0].x_only_public_key().0, None); + assert_failure(run(&changed), "no committed canonical multisig leaf"); + } + + #[test] + fn verifies_each_signature_and_all_transaction_inputs() { + let original = fixture(Leaves::Cooperative); + for key in keypairs() { + let mut changed = original.clone(); + let input = &mut changed.inputs[1]; + let entry = input + .tap_script_sigs + .iter_mut() + .find(|((public_key, _), _)| *public_key == key.x_only_public_key().0) + .unwrap(); + entry.1.signature = Secp256k1::new().sign_schnorr(&Message::from_digest([0; 32]), &key); + assert_failure(run(&changed), "Input 1 invalid tap_script_sig"); + } + let mut changed = original.clone(); + changed.inputs[1].tap_script_sigs.pop_first(); + assert_failure(run(&changed), "Input 1 missing tap_script_sig"); + let mut changed = original.clone(); + changed.unsigned_tx.output[0].value = btc::Amount::from_sat(180_000); + assert_failure(run(&changed), "invalid tap_script_sig"); + let mut changed = original.clone(); + changed.inputs[1].witness_utxo.as_mut().unwrap().value = btc::Amount::from_sat(99_999); + assert_failure(run(&changed), "invalid tap_script_sig"); + let mut changed = original.clone(); + changed.inputs[1].witness_utxo = None; + assert_failure(run(&changed), "Input 1 missing witness_utxo"); + } +} + +mod elements { + use super::*; + use el::{ + pset::PartiallySignedTransaction as Pset, + sighash::{Prevouts, SighashCache}, + taproot::{LeafVersion, TapLeafHash, TaprootBuilder}, + }; + + fn fixture(leaves: Leaves) -> Pset { + let secp = Secp256k1::new(); + let keys = keypairs(); + let [cosigner, user] = public_keys(&keys); + let mut leaf = p2tr_multisig_leaf_elements(&cosigner, &user); + if matches!(leaves, Leaves::OpcodeLookalike) { + let mut bytes = leaf.to_bytes(); + bytes[33] = el::opcodes::all::OP_DROP.into_u8(); + leaf = el::Script::from(bytes); + } + let other_leaf = if matches!(leaves, Leaves::Ambiguous) { + p2tr_multisig_leaf_elements(&user, &cosigner) + } else { + el::script::Builder::new().push_int(1).into_script() + }; + let spend_info = TaprootBuilder::new() + .add_leaf(1, leaf.clone()) + .unwrap() + .add_leaf(1, other_leaf.clone()) + .unwrap() + .finalize(&secp, *UNSPENDABLE_KEY_P2TR) + .unwrap(); + let tx = el::Transaction { + version: 2, + lock_time: el::LockTime::ZERO, + input: (0..2) + .map(|vout| el::TxIn { + previous_output: el::OutPoint { + txid: el::Txid::from_byte_array([0; 32]), + vout, + }, + ..el::TxIn::default() + }) + .collect(), + output: vec![el::TxOut { + asset: el::confidential::Asset::Explicit(el::AssetId::default()), + value: el::confidential::Value::Explicit(190_000), + ..el::TxOut::default() + }], + }; + let mut pset = Pset::from_tx(tx.clone()); + let prevouts = vec![ + el::TxOut { + asset: el::confidential::Asset::Explicit(el::AssetId::default()), + value: el::confidential::Value::Explicit(100_000), + script_pubkey: el::Script::new_v1_p2tr_tweaked(spend_info.output_key()), + ..el::TxOut::default() + }; + 2 + ]; + let leaf_hash = TapLeafHash::from_script(&leaf, LeafVersion::default()); + let control = spend_info + .control_block(&(leaf.clone(), LeafVersion::default())) + .unwrap(); + for (index, input) in pset.inputs_mut().iter_mut().enumerate() { + input.witness_utxo = Some(prevouts[index].clone()); + input + .tap_scripts + .insert(control.clone(), (leaf.clone(), LeafVersion::default())); + if matches!(leaves, Leaves::Ambiguous) { + let other_control = spend_info + .control_block(&(other_leaf.clone(), LeafVersion::default())) + .unwrap(); + input + .tap_scripts + .insert(other_control, (other_leaf.clone(), LeafVersion::default())); + } + for (key, hash_ty) in keys + .iter() + .zip([el::SchnorrSighashType::Default, el::SchnorrSighashType::All]) + { + let hash = SighashCache::new(&tx) + .taproot_script_spend_signature_hash( + index, + &Prevouts::All(&prevouts), + leaf_hash, + hash_ty, + ElementsNetwork::LiquidTestnet.genesis_hash(), + ) + .unwrap(); + input.tap_script_sigs.insert( + (key.x_only_public_key().0, leaf_hash), + el::SchnorrSig { + sig: secp.sign_schnorr(&Message::from_digest(hash.to_byte_array()), key), + hash_ty, + }, + ); + } + } + pset + } + + fn run(pset: &Pset) -> Output { + invoke(&[ + "tx", + "finalize", + "--pset", + &hex::encode(el::encode::serialize(pset)), + "--network", + "liquid_testnet", + ]) + } + + #[test] + fn earlier_decoys_do_not_change_a_verified_cooperative_witness() { + let original = fixture(Leaves::Cooperative); + let expected = success(run(&original)); + for swapped in [false, true] { + let mut pset = original.clone(); + for input in pset.inputs_mut() { + let (control, (leaf, version)) = input.tap_scripts.first_key_value().unwrap(); + let mut decoy = control.clone(); + decoy.merkle_branch = Default::default(); + let decoy_leaf = if swapped { + let [cosigner, user] = public_keys(&keypairs()); + p2tr_multisig_leaf_elements(&user, &cosigner) + } else { + el::script::Builder::new().push_int(1).into_script() + }; + assert!(decoy < *control); + assert_ne!(decoy_leaf, *leaf); + let version = *version; + input + .tap_scripts + .insert(decoy.clone(), (decoy_leaf, version)); + assert_eq!(input.tap_scripts.first_key_value().unwrap().0, &decoy); + input.final_script_witness = Some(vec![vec![1, 2, 3]].into()); + } + assert_eq!(success(run(&pset)), expected); + } + let tx: el::Transaction = el::encode::deserialize(&hex::decode(expected).unwrap()).unwrap(); + let [cosigner, user] = public_keys(&keypairs()); + let leaf = p2tr_multisig_leaf_elements(&cosigner, &user); + let hash = TapLeafHash::from_script(&leaf, LeafVersion::default()); + for (input, finalized) in original.inputs().iter().zip(&tx.input) { + let expected = [ + input.tap_script_sigs[&(user.inner.x_only_public_key().0, hash)].to_vec(), + input.tap_script_sigs[&(cosigner.inner.x_only_public_key().0, hash)].to_vec(), + leaf.to_bytes(), + input.tap_scripts.first_key_value().unwrap().0.serialize(), + ]; + assert_eq!( + finalized.witness.script_witness, + el::Witness::from(expected.to_vec()) + ); + } + } + + #[test] + fn rejects_committed_opcode_lookalikes_and_ambiguous_leaves() { + assert_failure( + run(&fixture(Leaves::OpcodeLookalike)), + "no committed canonical multisig leaf", + ); + assert_failure( + run(&fixture(Leaves::Ambiguous)), + "ambiguous committed multisig leaves", + ); + } + + #[test] + fn rejects_invalid_control_versions_and_foreign_output() { + let original = fixture(Leaves::Cooperative); + let mut changed = original.clone(); + let (control, value) = changed.inputs_mut()[0].tap_scripts.pop_first().unwrap(); + let mut invalid = control.clone(); + invalid.merkle_branch = Default::default(); + changed.inputs_mut()[0] + .tap_scripts + .insert(invalid, value.clone()); + assert_failure(run(&changed), "no committed canonical multisig leaf"); + let mut changed = original.clone(); + changed.inputs_mut()[0] + .tap_scripts + .insert(control, (value.0, LeafVersion::from_u8(0xc2).unwrap())); + assert_failure(run(&changed), "no committed canonical multisig leaf"); + let mut changed = original.clone(); + changed.inputs_mut()[0] + .witness_utxo + .as_mut() + .unwrap() + .script_pubkey = el::Script::new(); + assert_failure(run(&changed), "spent output is not P2TR"); + let mut changed = original.clone(); + changed.inputs_mut()[0] + .witness_utxo + .as_mut() + .unwrap() + .script_pubkey = + el::Script::new_v1_p2tr(&Secp256k1::new(), keypairs()[0].x_only_public_key().0, None); + assert_failure(run(&changed), "no committed canonical multisig leaf"); + } + + #[test] + fn verifies_each_signature_all_transaction_inputs_and_network() { + let original = fixture(Leaves::Cooperative); + for key in keypairs() { + let mut changed = original.clone(); + let input = &mut changed.inputs_mut()[1]; + let entry = input + .tap_script_sigs + .iter_mut() + .find(|((public_key, _), _)| *public_key == key.x_only_public_key().0) + .unwrap(); + entry.1.sig = Secp256k1::new().sign_schnorr(&Message::from_digest([0; 32]), &key); + assert_failure(run(&changed), "Input 1 invalid tap_script_sig"); + } + let mut changed = original.clone(); + changed.inputs_mut()[1].tap_script_sigs.pop_first(); + assert_failure(run(&changed), "Input 1 missing tap_script_sig"); + let mut changed = original.clone(); + changed.outputs_mut()[0].amount = Some(180_000); + assert_failure(run(&changed), "invalid tap_script_sig"); + let mut changed = original.clone(); + changed.inputs_mut()[1].witness_utxo.as_mut().unwrap().value = + el::confidential::Value::Explicit(99_999); + assert_failure(run(&changed), "invalid tap_script_sig"); + let mut changed = original.clone(); + changed.inputs_mut()[1].witness_utxo = None; + assert_failure(run(&changed), "Input 1 missing witness_utxo"); + assert_failure( + invoke(&[ + "tx", + "finalize", + "--pset", + &hex::encode(el::encode::serialize(&original)), + "--network", + "liquid", + ]), + "invalid tap_script_sig", + ); + } +} diff --git a/core/README.md b/core/README.md index fd72ea3..23122db 100644 --- a/core/README.md +++ b/core/README.md @@ -1,6 +1,6 @@ # Simplicity Unchained Core -Contains the fundamental logic for executing Simplicity programs and interacting with the Elements blockchain. +Contains the fundamental logic for executing Simplicity programs with Bitcoin and Elements environments. ## Capabilities @@ -16,7 +16,7 @@ The core idea is to bind a Simplicity program to a co-signer's key using taproot 1. The user writes a Simplicity program encoding the conditions they want enforced (e.g. a signature check, a hash preimage, an oracle assertion). 2. The service computes the **CMR** (Commitment Merkle Root) of the program — a 32-byte hash that uniquely identifies the program's structure and semantics. -3. The service tweaks its own internal key with the CMR using the standard taproot tweak: `tweaked_key = tap_tweak(service_key, CMR)`. This binds the co-signer's key irrevocably to that program. +3. The service commits to both the CMR and the exact script bytes exposed to script-reading jets, then applies the standard taproot tweak: `tweaked_key = tap_tweak(service_key, signing_commitment(CMR, redeem_script))`. This binds the co-signer's key to that program and its script data. 4. A P2TR address is constructed using the tweaked co-signer key and the user's key (see below). Funds sent to this address can only move with the oracle's cooperation — and the oracle will only cooperate if the Simplicity program passes. ### Taproot Tree Structure @@ -35,7 +35,7 @@ P2TR output **Internal key**: A provably unspendable key is used as the internal key. -**Leaf 0 — Co-signing leaf**: A 2-of-2 Schnorr multisig script. Both the co-signer and the user must sign. The co-signer's x-only key embedded here is the CMR-tweaked service key, so this leaf is only spendable with signatures from a service that has already executed the matching Simplicity program successfully. +**Leaf 0 — Co-signing leaf**: A 2-of-2 Schnorr multisig script. Both the co-signer and the user must sign. The co-signer's x-only key embedded here commits to the program and script data, so this leaf is only spendable with signatures from a service that has already executed the matching Simplicity program with the matching script successfully. **Leaf 1 — User recovery leaf**: A user-controlled script (example is a CSV timelock + user key) that allows the user to recover funds unilaterally after the timelock expires, with no involvement from the service. The service receives only the *hash* of this leaf (`user_leaf_hash`) and never sees the script itself, preserving the user's privacy for the recovery path. @@ -43,13 +43,73 @@ P2TR output When a user wants to spend from the address: -1. The user constructs a PSBT/PSET spending transaction and submits it to the service along with the Simplicity program, any required witness data, their public key, and the user leaf hash. -2. The service executes the Simplicity program against the transaction environment. -3. If execution succeeds, the service re-derives the tweaked co-signer key from the CMR, reconstructs the taproot tree (co-signing leaf + hidden user leaf), computes the script-path sighash for Leaf 0, and returns a Schnorr signature. +1. The user constructs a PSBT/PSET spending transaction and submits it to the service along with the Simplicity program, the same `redeem_script_hex` used at address generation, any required witness data, their public key, and the user leaf hash. +2. The service executes the Simplicity program against the transaction environment. Per-spend authorization requires the program to actually verify a transaction-dependent condition; a hash preimage or signature over a fixed message remains reusable. +3. If execution succeeds, the service re-derives the tweaked co-signer key from the CMR and script, reconstructs the taproot tree (co-signing leaf + hidden user leaf), checks that it matches the input's `witness_utxo.script_pubkey`, computes the script-path sighash for Leaf 0, and returns a Schnorr signature. 4. The user adds their own signature for the same leaf and finalizes the transaction. If the Simplicity program fails, the service refuses to sign and the funds remain locked. +### Bitcoin Per-Spend Approvals + +The Bitcoin extension exposes `tx_approval_hash: ONE -> 2^256`. A policy requiring +an approver's authorization for each spend must verify a BIP-340 signature over +this digest, not a constant message or a requester-supplied digest: + +```text +approval_hash = jet::tx_approval_hash() +jet::bip_0340_verify((committed_approver_pubkey, approval_hash), witness_signature) +``` + +The approver can calculate the exact digest locally using +`bitcoin_execution_environment` or the CLI's +[`btc-tx approval-hash`](../cli/README.md#bitcoin-transaction-approvals) command. +All inputs must have `witness_utxo` populated. The approver must independently +verify those prevouts and review the transaction before signing the digest. + +The version 1 digest is a single SHA256 hash of this concatenation: + +```text +tag_hash || tag_hash || network_id || input_index_be32 || +signing_commitment(CMR, redeem_script) || +SHA256(bitcoin_consensus_encode(unsigned_transaction)) || +SHA256(bitcoin_consensus_encode(prevouts_vector)) +``` + +`tag_hash = SHA256("SimplicityUnchained/BitcoinApproval/v1")`. +`network_id` is one byte: `0` for `bitcoin`, `1` for `testnet`, `2` for `testnet4`. +The input index is four bytes, big-endian. The prevouts vector contains each +input's `witness_utxo` in transaction order, including its value and script; its +Bitcoin consensus encoding includes the CompactSize vector length. All hashes +here are single SHA256, not SHA256d. The program/script commitment is defined +below. + +Changing outputs, input outpoints, sequences, amounts, scripts, locktime, version, +selected input, network, or program/script commitment changes the digest. PSBT +signatures and other non-transaction metadata are not included. Repeating an +identical approved transaction is allowed; this is not a one-use-token registry. + +This is a dedicated off-chain **approval hash**, not a BIP-341 signing sighash and +not the Elements `sig_all_hash` jet. The service still produces its cooperative +Taproot signature over the actual BIP-341 sighash. The dependency's native +Bitcoin transaction jets are not implemented; programs must use the updated +`CoreExtension` jet table. The new custom jet uses encoding `1111111` and CMR +`SHA256("SimplicityUnchained/Jet/tx_approval_hash/v1")`. + +**Compatibility:** existing jet encodings, CMRs and static-policy addresses are +unchanged. Static Bitcoin policies remain intentionally replayable. Adding this +jet to an existing policy changes its CMR and address; already-funded static +contracts do not gain per-spend approval automatically. Deploy the new policy +and migrate funds using an available original spending path. + +### Script Commitment and Migration + +`signing_commitment` is the 32-byte tagged SHA256 hash +`SHA256(tag_hash || tag_hash || CMR || SHA256(redeem_script))`, where +`tag_hash = SHA256("SimplicityUnchained/SigningCommitment/v1")`. +The original program CMR is unchanged. Omitted, null, and empty scripts all mean +the same zero-length byte string, which is also committed. + ## Licence See the [LICENCE](../LICENCE) file for details. diff --git a/core/src/jets/base.rs b/core/src/jets/base.rs index 4e3eb56..c688359 100644 --- a/core/src/jets/base.rs +++ b/core/src/jets/base.rs @@ -38,6 +38,12 @@ impl AsBaseEnv for UnchainedEnv { } } +impl AsBaseEnv for BitcoinUnchainedEnv { + fn base_env(&self) -> &CoreEnv { + &self.env.core_env + } +} + impl BaseJet for Core { type Env = CoreEnv; const ALL: &'static [Self] = &Core::ALL; @@ -65,6 +71,7 @@ impl BaseJet for CoreExtension { CoreExtension::Core(inner) => inner.index(), CoreExtension::GetOpcodeFromScript => Core::ALL.len(), CoreExtension::GetPubkeyFromScript => Core::ALL.len() + 1, + CoreExtension::TxApprovalHash => Core::ALL.len() + 2, } } } diff --git a/core/src/jets/bitcoin.rs b/core/src/jets/bitcoin.rs index 78039c5..feb3420 100644 --- a/core/src/jets/bitcoin.rs +++ b/core/src/jets/bitcoin.rs @@ -5,11 +5,11 @@ use simplicity::Cmr; use simplicity::Cost; use simplicity::ffi::CFrameItem; use simplicity::jet::type_name::TypeName; -use simplicity::jet::{Core, CoreEnv, Jet, JetEnvironment}; +use simplicity::jet::{Core, Jet, JetEnvironment}; use simplicity::{BitIter, BitWriter, decode}; use super::base::base_c_jet_ptr; -use super::environments::BitcoinUnchainedEnv; +use super::environments::{BitcoinTxEnv, BitcoinUnchainedEnv}; // Local version of decode_bits macro that accepts expressions instead of just paths macro_rules! decode_bits { @@ -33,12 +33,13 @@ pub enum CoreExtension { Core(Core), GetOpcodeFromScript, GetPubkeyFromScript, + TxApprovalHash, } impl CoreExtension { pub const ALL: [Self; Self::ALL_JETS_NUM] = Self::build_all_variants(); - const ALL_JETS_NUM: usize = Core::ALL.len() + 2; + const ALL_JETS_NUM: usize = Core::ALL.len() + 3; const fn build_all_variants() -> [Self; Self::ALL_JETS_NUM] { struct AllVariantsBuilder { @@ -78,6 +79,7 @@ impl CoreExtension { builder.push(CoreExtension::GetOpcodeFromScript); builder.push(CoreExtension::GetPubkeyFromScript); + builder.push(CoreExtension::TxApprovalHash); builder.finalize() } @@ -100,8 +102,13 @@ impl JetEnvironment for BitcoinUnchainedEnv { // rest of elements jets CoreExtension::Core(inner_jet) => base_c_jet_ptr(inner_jet), // custom jets - CoreExtension::GetOpcodeFromScript => super::exec::get_opcode_from_script::, - CoreExtension::GetPubkeyFromScript => super::exec::get_pubkey_from_script::, + CoreExtension::GetOpcodeFromScript => { + super::exec::get_opcode_from_script:: + } + CoreExtension::GetPubkeyFromScript => { + super::exec::get_pubkey_from_script:: + } + CoreExtension::TxApprovalHash => super::exec::bitcoin_tx_approval_hash, } } } @@ -123,6 +130,9 @@ impl Jet for CoreExtension { 0xd2, 0x52, 0x0e, 0xcc, 0x02, 0x09, 0x00, 0x67, 0xc8, 0xfc, 0xca, 0xbb, 0x4d, 0x72, 0xa6, 0x55, 0xcd, 0xcb, ], + CoreExtension::TxApprovalHash => hex_literal::hex!( + "f0ed96ee8fbf1a69beb20deb3a7a9b748bdc18da284d15438debc7d4faeb9962" + ), _ => unreachable!(), }; @@ -137,6 +147,7 @@ impl Jet for CoreExtension { let name = match self { CoreExtension::GetOpcodeFromScript => b"c", CoreExtension::GetPubkeyFromScript => b"c", + CoreExtension::TxApprovalHash => b"1", _ => unreachable!(), }; @@ -151,6 +162,7 @@ impl Jet for CoreExtension { let name = match self { CoreExtension::GetOpcodeFromScript => b"c", CoreExtension::GetPubkeyFromScript => b"h", + CoreExtension::TxApprovalHash => b"h", _ => unreachable!(), }; @@ -165,6 +177,7 @@ impl Jet for CoreExtension { let (n, len) = match self { CoreExtension::GetOpcodeFromScript => (62, 6), CoreExtension::GetPubkeyFromScript => (126, 7), + CoreExtension::TxApprovalHash => (127, 7), _ => unreachable!(), }; @@ -213,7 +226,7 @@ impl Jet for CoreExtension { 0 => {CoreExtension::GetOpcodeFromScript}, 1 => { 0 => {CoreExtension::GetPubkeyFromScript}, - 1 => {} + 1 => {CoreExtension::TxApprovalHash} } } } @@ -243,6 +256,7 @@ impl Jet for CoreExtension { match self { CoreExtension::GetOpcodeFromScript => Cost::from_milliweight(100), CoreExtension::GetPubkeyFromScript => Cost::from_milliweight(100), + CoreExtension::TxApprovalHash => Cost::from_milliweight(356), _ => unreachable!(), } } @@ -258,6 +272,7 @@ impl std::fmt::Display for CoreExtension { CoreExtension::Core(inner_jet) => f.write_str(&inner_jet.to_string()), CoreExtension::GetOpcodeFromScript => f.write_str("get_opcode_from_script"), CoreExtension::GetPubkeyFromScript => f.write_str("get_pubkey_from_script"), + CoreExtension::TxApprovalHash => f.write_str("tx_approval_hash"), } } } @@ -269,6 +284,7 @@ impl std::str::FromStr for CoreExtension { match s { "get_opcode_from_script" => Ok(CoreExtension::GetOpcodeFromScript), "get_pubkey_from_script" => Ok(CoreExtension::GetPubkeyFromScript), + "tx_approval_hash" => Ok(CoreExtension::TxApprovalHash), _ => { let inner_jet = s.parse::()?; Ok(CoreExtension::Core(inner_jet)) diff --git a/core/src/jets/environments.rs b/core/src/jets/environments.rs index 80b2618..d288b87 100644 --- a/core/src/jets/environments.rs +++ b/core/src/jets/environments.rs @@ -1,9 +1,20 @@ use simplicity::elements::script::Script; use simplicity::jet::{CoreEnv, ElementsTxEnv}; -pub type BitcoinUnchainedEnv = UnchainedEnv; +pub type BitcoinUnchainedEnv = UnchainedEnv; pub type ElementsUnchainedEnv = UnchainedEnv; +/// Transaction context exposed to Bitcoin custom jets and plugins. +pub struct BitcoinTxEnv { + /// Unsigned spending transaction, without PSBT signature metadata. + pub tx: simplicity::bitcoin::Transaction, + /// Input being authorized. + pub input_index: u32, + /// Versioned per-spend approval digest, distinct from the Taproot signing sighash. + pub approval_hash: [u8; 32], + pub(crate) core_env: CoreEnv, +} + pub struct UnchainedEnv { pub redeem_script: Script, pub env: E, diff --git a/core/src/jets/exec.rs b/core/src/jets/exec.rs index 21e0adc..e76bdbb 100644 --- a/core/src/jets/exec.rs +++ b/core/src/jets/exec.rs @@ -6,7 +6,21 @@ use simplicity::{ use crate::jets::environments::ElementsUnchainedEnv; -use super::environments::UnchainedEnv; +use super::environments::{BitcoinUnchainedEnv, UnchainedEnv}; + +/// ONE |- 2^256 +/// +/// Returns the versioned Bitcoin transaction approval digest computed by the runner. +pub fn bitcoin_tx_approval_hash( + dst: &mut CFrameItem, + _src: CFrameItem, + env: &BitcoinUnchainedEnv, +) -> bool { + for byte in env.env.approval_hash { + write_bits(dst, byte.into(), 8); + } + true +} /// Reads a big-endian `n`-bit value from a C read frame. fn read_bits(src: &mut CFrameItem, n: u32) -> u64 { diff --git a/core/src/jets/mod.rs b/core/src/jets/mod.rs index c0794c0..afb20a4 100644 --- a/core/src/jets/mod.rs +++ b/core/src/jets/mod.rs @@ -62,6 +62,42 @@ pub(crate) mod tests { } } + #[test] + fn bitcoin_custom_jet_encodings_and_identities_are_stable() { + for (jet, expected) in [ + (CoreExtension::GetOpcodeFromScript, 0xf8u8), + (CoreExtension::GetPubkeyFromScript, 0xfc), + (CoreExtension::TxApprovalHash, 0xfe), + ] { + let mut encoded = Vec::new(); + let mut writer = BitWriter::new(&mut encoded as &mut dyn std::io::Write); + jet.encode(&mut writer).unwrap(); + writer.flush_all().unwrap(); + assert_eq!(encoded, [expected]); + } + assert_eq!( + hex::encode(CoreExtension::GetOpcodeFromScript.cmr().to_byte_array()), + "dcccd2895922e75b018b0846e5cd4963808bbfd48b47234475607f90e70ee032", + ); + assert_eq!( + hex::encode(CoreExtension::GetPubkeyFromScript.cmr().to_byte_array()), + "27eab09068b035af619713335b73d2520ecc02090067c8fccabb4d72a655cdcb", + ); + assert_eq!( + CoreExtension::parse("tx_approval_hash").unwrap(), + CoreExtension::TxApprovalHash, + ); + assert_eq!( + CoreExtension::TxApprovalHash.to_string(), + "tx_approval_hash" + ); + assert_eq!( + CoreExtension::TxApprovalHash.cmr().to_byte_array(), + simplicity::hashes::sha256::Hash::hash(b"SimplicityUnchained/Jet/tx_approval_hash/v1",) + .to_byte_array(), + ); + } + #[test] fn test_get_opcode() { let script = Script::from_hex_no_prefix( diff --git a/core/src/plugin/README.md b/core/src/plugin/README.md index 91cdc4a..457b040 100644 --- a/core/src/plugin/README.md +++ b/core/src/plugin/README.md @@ -13,3 +13,16 @@ 3. Programs are parsed and run with `PluginJet` and `PluginEnv`. Base jets run through the host's own implementation; custom jets are called through the function pointers from the descriptor. + +## Bitcoin transaction context + +Bitcoin plugin environments now contain the consensus-serialized unsigned +transaction and the actual selected input index rather than empty transaction +bytes and index zero. Elements behavior and the `JetEnv` ABI layout are unchanged. + +The Bitcoin base set adds `tx_approval_hash` (`ONE -> 2^256`, code `1111111`). +Existing base jet encodings, CMRs and indices are unchanged. Plugin descriptors +must not use this newly reserved encoding or a conflicting prefix; the loader +rejects such conflicts. See the core +[approval-hash protocol](../../README.md#bitcoin-per-spend-approvals) for its +semantics and the distinction from a Taproot signing sighash. diff --git a/core/src/plugin/env.rs b/core/src/plugin/env.rs index a4698d8..3f3a5f2 100644 --- a/core/src/plugin/env.rs +++ b/core/src/plugin/env.rs @@ -32,12 +32,12 @@ impl PluginBase for CoreExtension { env.redeem_script.as_bytes() } - fn tx(_env: &Self::Env) -> Vec { - Vec::new() + fn tx(env: &Self::Env) -> Vec { + simplicity::bitcoin::consensus::serialize(&env.env.tx) } - fn input_index(_env: &Self::Env) -> u32 { - 0 + fn input_index(env: &Self::Env) -> u32 { + env.env.input_index } } diff --git a/core/src/runner.rs b/core/src/runner.rs index 8a5412c..d535eb8 100644 --- a/core/src/runner.rs +++ b/core/src/runner.rs @@ -1,11 +1,11 @@ use std::sync::Arc; -use simplicity::BitMachine; use simplicity::bit_machine::ExecutionError; use simplicity::bitcoin::psbt; use simplicity::elements::taproot::{LeafVersion, TaprootMerkleBranch}; use simplicity::elements::{BlockHash, Script}; use simplicity::elements::{Transaction, pset, schnorr::UntweakedPublicKey}; +use simplicity::{BitMachine, Cost, RedeemNode}; use simplicity::{ Cmr, elements::taproot::ControlBlock, @@ -17,17 +17,27 @@ use hex_literal::hex; use crate::jets::bitcoin::CoreExtension; use crate::jets::elements::ElementsExtension; -use crate::jets::environments::UnchainedEnv; +use crate::jets::environments::{BitcoinTxEnv, UnchainedEnv}; use crate::program::Program; +use crate::utils::signing_commitment; use crate::{BitcoinNetwork, ElementsNetwork}; pub struct SimplicityRunner; +/// Maximum execution cost accepted by the off-chain runner, in milliweight. +pub const MAX_EXECUTION_COST: Cost = Cost::from_milliweight(10_000_000); + #[derive(Debug, thiserror::Error)] pub enum RunnerError { - #[error("input index {index} out-of-range for PSET with {total} inputs")] + #[error("input index {index} out-of-range for transaction with {total} inputs")] InputIndexOutOfRange { index: usize, total: usize }, + #[error("PSBT input count {psbt_inputs} does not match transaction input count {tx_inputs}")] + PsbtInputCountMismatch { + psbt_inputs: usize, + tx_inputs: usize, + }, + #[error("could not find Simplicity leaf in PSET taptree with CMR {cmr})")] MissingSimplicityLeaf { cmr: String }, @@ -43,6 +53,9 @@ pub enum RunnerError { #[error("program does not have a redeem node")] NoRedeemNode, + #[error("program execution cost {cost} exceeds maximum {max} milliweight")] + ExecutionCostExceeded { cost: Cost, max: Cost }, + #[error("failed to construct bit machine: {0}")] BitMachineConstruction(simplicity::bit_machine::LimitError), @@ -62,40 +75,37 @@ impl SimplicityRunner { let program = Program::::from_str(program, witness) .map_err(RunnerError::ProgramParse)?; + let redeem_node = program.redeem_node().ok_or(RunnerError::NoRedeemNode)?; + let mut mac = bounded_machine(redeem_node)?; + let elements_env = elements_execution_environment(pset, input_idx, program.cmr(), network.genesis_hash())?; let env = UnchainedEnv::new(redeem_script, elements_env); - let redeem_node = program.redeem_node().ok_or(RunnerError::NoRedeemNode)?; - - let mut mac = - BitMachine::for_program(redeem_node).map_err(RunnerError::BitMachineConstruction)?; - mac.exec(redeem_node, &env) .map_err(RunnerError::ExecutionError)?; Ok(program.commit_prog().cmr()) } - // NOTE: We will need unused parameters later when we will add more jets. pub fn execute_bitcoin( program: &str, witness: Option<&str>, - _input_idx: usize, - _psbt: &psbt::Psbt, + input_idx: usize, + psbt: &psbt::Psbt, redeem_script: Script, - _network: BitcoinNetwork, + network: BitcoinNetwork, ) -> Result { let program = Program::::from_str(program, witness) .map_err(RunnerError::ProgramParse)?; - let env = UnchainedEnv::new(redeem_script, CoreEnv::new()); - let redeem_node = program.redeem_node().ok_or(RunnerError::NoRedeemNode)?; + let mut mac = bounded_machine(redeem_node)?; - let mut mac = - BitMachine::for_program(redeem_node).map_err(RunnerError::BitMachineConstruction)?; + let bitcoin_env = + bitcoin_execution_environment(psbt, input_idx, program.cmr(), &redeem_script, network)?; + let env = UnchainedEnv::new(redeem_script, bitcoin_env); mac.exec(redeem_node, &env) .map_err(RunnerError::ExecutionError)?; @@ -104,6 +114,86 @@ impl SimplicityRunner { } } +fn check_execution_cost(cost: Cost) -> Result<(), RunnerError> { + if cost > MAX_EXECUTION_COST { + return Err(RunnerError::ExecutionCostExceeded { + cost, + max: MAX_EXECUTION_COST, + }); + } + Ok(()) +} + +fn bounded_machine(program: &RedeemNode) -> Result { + check_execution_cost(program.bounds().cost)?; + BitMachine::for_program(program).map_err(RunnerError::BitMachineConstruction) +} + +/// Builds the Bitcoin context and `tx_approval_hash` digest for a selected input. +/// +/// The digest commits to the network, input index, program/script commitment, +/// unsigned transaction and all `witness_utxo` values and scripts. It is not a +/// BIP-341 sighash; approvers must independently verify the PSBT's prevouts. +pub fn bitcoin_execution_environment( + psbt: &psbt::Psbt, + input_idx: usize, + cmr: Cmr, + redeem_script: &Script, + network: BitcoinNetwork, +) -> Result { + use simplicity::bitcoin::{ + consensus::serialize, + hashes::{Hash, HashEngine, sha256}, + }; + + let tx_inputs = psbt.unsigned_tx.input.len(); + if psbt.inputs.len() != tx_inputs { + return Err(RunnerError::PsbtInputCountMismatch { + psbt_inputs: psbt.inputs.len(), + tx_inputs, + }); + } + let input_index = u32::try_from(input_idx) + .ok() + .filter(|_| input_idx < tx_inputs) + .ok_or(RunnerError::InputIndexOutOfRange { + index: input_idx, + total: tx_inputs, + })?; + let prevouts = psbt + .inputs + .iter() + .enumerate() + .map(|(index, input)| { + input + .witness_utxo + .clone() + .ok_or(RunnerError::MissingWitnessUtxo(index)) + }) + .collect::, _>>()?; + let network_id = match network { + BitcoinNetwork::Mainnet => 0, + BitcoinNetwork::Testnet => 1, + BitcoinNetwork::Testnet4 => 2, + }; + let tag = sha256::Hash::hash(b"SimplicityUnchained/BitcoinApproval/v1"); + let mut engine = sha256::Hash::engine(); + engine.input(tag.as_byte_array()); + engine.input(tag.as_byte_array()); + engine.input(&[network_id]); + engine.input(&input_index.to_be_bytes()); + engine.input(&signing_commitment(cmr, redeem_script.as_bytes())); + engine.input(sha256::Hash::hash(&serialize(&psbt.unsigned_tx)).as_byte_array()); + engine.input(sha256::Hash::hash(&serialize(&prevouts)).as_byte_array()); + + Ok(BitcoinTxEnv { + tx: psbt.unsigned_tx.clone(), + input_index, + approval_hash: sha256::Hash::from_engine(engine).to_byte_array(), + core_env: CoreEnv::new(), + }) +} + pub fn elements_execution_environment( pset: &pset::PartiallySignedTransaction, input_idx: usize, @@ -156,3 +246,269 @@ fn dummy_control_block() -> ControlBlock { merkle_branch: TaprootMerkleBranch::default(), } } + +#[cfg(test)] +mod tests { + use super::*; + use simplicity::{ + ConstructNode, + base64::prelude::{BASE64_STANDARD, Engine as _}, + node::CoreConstructible, + types, + }; + + fn shared_program(levels: usize) -> String { + let bytes = types::Context::with_context(|ctx| { + let mut node = Arc::::unit(&ctx); + for _ in 0..levels { + node = Arc::::comp(&node, &node).unwrap(); + } + node.finalize_types().unwrap().to_vec_without_witness() + }); + assert!(bytes.len() < 256); + BASE64_STANDARD.encode(bytes) + } + + #[test] + fn execution_cost_boundary() { + assert!(check_execution_cost(MAX_EXECUTION_COST).is_ok()); + assert!(matches!( + check_execution_cost(MAX_EXECUTION_COST + Cost::from_milliweight(1)), + Err(RunnerError::ExecutionCostExceeded { .. }) + )); + assert!(check_execution_cost(Cost::from_milliweight(u32::MAX)).is_err()); + } + + #[test] + fn both_runners_reject_expensive_shared_dags() { + let tx = simplicity::bitcoin::Transaction { + version: simplicity::bitcoin::transaction::Version::TWO, + lock_time: simplicity::bitcoin::absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }; + let psbt = psbt::Psbt::from_unsigned_tx(tx).unwrap(); + let pset = pset::PartiallySignedTransaction::new_v2(); + for levels in [20, 60] { + let encoded = shared_program(levels); + let program = Program::::from_str(&encoded, Some("")).unwrap(); + let redeem = program.redeem_node().unwrap(); + // Memory checks alone accept these tiny, exponentially costly DAGs. + assert!(BitMachine::for_program(redeem).is_ok()); + assert!(redeem.bounds().cost > MAX_EXECUTION_COST); + if levels == 20 { + assert!(redeem.bounds().cost.is_consensus_valid()); + } + assert!(matches!( + SimplicityRunner::execute_bitcoin( + &encoded, + Some(""), + 0, + &psbt, + Script::new(), + BitcoinNetwork::Testnet, + ), + Err(RunnerError::ExecutionCostExceeded { .. }) + )); + assert!(matches!( + SimplicityRunner::execute_elements( + &encoded, + Some(""), + 0, + &pset, + Script::new(), + ElementsNetwork::LiquidTestnet, + ), + Err(RunnerError::ExecutionCostExceeded { .. }) + )); + } + } + + #[test] + fn inexpensive_program_still_executes() { + let encoded = shared_program(3); + let program = Program::::from_str(&encoded, Some("")).unwrap(); + let redeem = program.redeem_node().unwrap(); + let mut machine = bounded_machine(redeem).unwrap(); + let env = bitcoin_execution_environment( + &approval_psbt(), + 0, + program.cmr(), + &Script::new(), + BitcoinNetwork::Testnet, + ) + .unwrap(); + machine + .exec(redeem, &UnchainedEnv::new(Script::new(), env)) + .unwrap(); + } + + fn approval_psbt() -> psbt::Psbt { + use simplicity::bitcoin::{self, Amount, TxIn, TxOut}; + let mut inputs = vec![TxIn::default(), TxIn::default()]; + inputs[0].previous_output.vout = 0; + inputs[1].previous_output.vout = 1; + let mut psbt = psbt::Psbt::from_unsigned_tx(bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: inputs, + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: bitcoin::ScriptBuf::from_bytes(vec![0x51]), + }], + }) + .unwrap(); + for input in &mut psbt.inputs { + input.witness_utxo = Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: bitcoin::ScriptBuf::from_bytes(vec![0x51]), + }); + } + psbt + } + + #[test] + fn approval_hash_binds_transaction_prevouts_input_network_and_policy() { + use simplicity::bitcoin::{self, Amount, Sequence}; + let psbt = approval_psbt(); + let cmr = Cmr::from_byte_array([1; 32]); + let script = Script::from(vec![0x52]); + let hash = |psbt: &psbt::Psbt| { + bitcoin_execution_environment(psbt, 0, cmr, &script, BitcoinNetwork::Testnet) + .unwrap() + .approval_hash + }; + let original = hash(&psbt); + assert_eq!( + hex::encode(original), + "1b05942d9d96d9625b39f626593058f550e999463bd361f77512af00a7a20a8d", + ); + let mut variants = Vec::new(); + let mut changed = psbt.clone(); + changed.unsigned_tx.output[0].value = Amount::from_sat(49_999); + variants.push(changed); + let mut changed = psbt.clone(); + changed.unsigned_tx.output[0].script_pubkey = bitcoin::ScriptBuf::from_bytes(vec![0x52]); + variants.push(changed); + let mut changed = psbt.clone(); + changed.unsigned_tx.lock_time = bitcoin::absolute::LockTime::from_consensus(1); + variants.push(changed); + let mut changed = psbt.clone(); + changed.unsigned_tx.version = bitcoin::transaction::Version::ONE; + variants.push(changed); + let mut changed = psbt.clone(); + changed.unsigned_tx.input[0].sequence = Sequence::from_consensus(1); + variants.push(changed); + let mut changed = psbt.clone(); + changed.unsigned_tx.input[0].previous_output.vout = 2; + variants.push(changed); + for index in 0..2 { + let mut changed = psbt.clone(); + changed.inputs[index].witness_utxo.as_mut().unwrap().value = Amount::from_sat(99_999); + variants.push(changed); + let mut changed = psbt.clone(); + changed.inputs[index] + .witness_utxo + .as_mut() + .unwrap() + .script_pubkey = bitcoin::ScriptBuf::from_bytes(vec![0x52]); + variants.push(changed); + } + for (index, changed) in variants.iter().enumerate() { + assert_ne!(original, hash(changed), "mutation {index}"); + } + for (index, cmr, script, network) in [ + (1, cmr, script.clone(), BitcoinNetwork::Testnet), + (0, cmr, script.clone(), BitcoinNetwork::Mainnet), + (0, cmr, script.clone(), BitcoinNetwork::Testnet4), + ( + 0, + Cmr::from_byte_array([2; 32]), + script.clone(), + BitcoinNetwork::Testnet, + ), + (0, cmr, Script::new(), BitcoinNetwork::Testnet), + ] { + assert_ne!( + original, + bitcoin_execution_environment(&psbt, index, cmr, &script, network) + .unwrap() + .approval_hash + ); + } + assert_eq!( + original, + bitcoin_execution_environment(&psbt, 0, cmr, &script, BitcoinNetwork::Testnet) + .unwrap() + .approval_hash, + ); + } + + #[test] + fn bitcoin_context_rejects_missing_or_inconsistent_inputs() { + let mut psbt = approval_psbt(); + let context = |psbt: &psbt::Psbt, index| { + bitcoin_execution_environment( + psbt, + index, + Cmr::from_byte_array([1; 32]), + &Script::new(), + BitcoinNetwork::Testnet, + ) + }; + assert!(matches!( + context(&psbt, 2), + Err(RunnerError::InputIndexOutOfRange { .. }) + )); + psbt.inputs[1].witness_utxo = None; + assert!(matches!( + context(&psbt, 0), + Err(RunnerError::MissingWitnessUtxo(1)) + )); + psbt.inputs.pop(); + assert!(matches!( + context(&psbt, 0), + Err(RunnerError::PsbtInputCountMismatch { .. }) + )); + } + + #[test] + fn approval_jet_returns_the_context_digest_and_plugins_receive_the_transaction() { + use crate::plugin::PluginBase; + use simplicity::{Word, jet::Core}; + let psbt = approval_psbt(); + let env = UnchainedEnv::new( + Script::new(), + bitcoin_execution_environment( + &psbt, + 1, + Cmr::from_byte_array([1; 32]), + &Script::new(), + BitcoinNetwork::Testnet, + ) + .unwrap(), + ); + let redeem = types::Context::with_context(|ctx| { + let actual = Arc::::jet(&ctx, &CoreExtension::TxApprovalHash); + let expected = + Arc::::const_word(&ctx, Word::u256(env.env.approval_hash)); + let pair = Arc::::pair(&actual, &expected).unwrap(); + let eq = Arc::::jet(&ctx, &CoreExtension::Core(Core::Eq256)); + let equal = Arc::::comp(&pair, &eq).unwrap(); + let verify = Arc::::jet(&ctx, &CoreExtension::Core(Core::Verify)); + Arc::::comp(&equal, &verify) + .unwrap() + .finalize_unpruned() + .unwrap() + }); + bounded_machine(&redeem) + .unwrap() + .exec(&redeem, &env) + .unwrap(); + assert_eq!(CoreExtension::input_index(&env), 1); + assert_eq!( + CoreExtension::tx(&env), + simplicity::bitcoin::consensus::serialize(&psbt.unsigned_tx) + ); + } +} diff --git a/core/src/utils.rs b/core/src/utils.rs index 250908b..d72aa6b 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -9,7 +9,7 @@ use simplicity::elements::{ secp256k1_zkp::{Secp256k1, SecretKey, rand::rngs::OsRng}, }; use simplicity::hashes::{HashEngine, sha256}; -use simplicity::{ToXOnlyPubkey, bitcoin}; +use simplicity::{Cmr, ToXOnlyPubkey, bitcoin}; use thiserror::Error; @@ -28,6 +28,20 @@ pub enum UtilsError { const SIMPLICITY_TAG_PREFIX: &[u8] = b"Simplicity\x1fCommitment\x1f"; +/// Version 1 key commitment: tagged SHA256 of `CMR || SHA256(redeem_script)`. +/// +/// The script bytes must be identical at address generation and execution time. +/// Empty scripts are committed too; this never falls back to a CMR-only tweak. +pub fn signing_commitment(cmr: Cmr, redeem_script: &[u8]) -> [u8; 32] { + let tag = sha256::Hash::hash(b"SimplicityUnchained/SigningCommitment/v1"); + let mut engine = sha256::Hash::engine(); + engine.input(tag.as_byte_array()); + engine.input(tag.as_byte_array()); + engine.input(&cmr.to_byte_array()); + engine.input(sha256::Hash::hash(redeem_script).as_byte_array()); + sha256::Hash::from_engine(engine).to_byte_array() +} + const JETIV: sha256::Midstate = sha256::Midstate::new( [ 0x95, 0x32, 0xee, 0x28, 0xcd, 0xca, 0x69, 0xde, 0xc8, 0xa0, 0xa2, 0x18, 0xb7, 0x9b, 0xe3, @@ -93,6 +107,8 @@ pub enum SigExtractionError { InvalidPubKey(usize, usize), #[error("Input {0}: expected 32-byte xonly pk at position {1}")] ExpectedPubkey(usize, usize), + #[error("Input {0}: unexpected opcode at position {1}")] + UnexpectedOpcode(usize, usize), #[error("Input {0}: recovery key does not match multisig user key")] RecoveryKeyMismatch(usize), } @@ -155,7 +171,7 @@ pub fn extract_pubkeys_from_p2tr_multisig_leaf_btc( input_index: usize, ) -> Result<[bitcoin::XOnlyPublicKey; 2], SigExtractionError> { let instructions = script - .instructions() + .instructions_minimal() .collect::, _>>() .map_err(|_| SigExtractionError::ParseFail(input_index))?; @@ -168,6 +184,17 @@ pub fn extract_pubkeys_from_p2tr_multisig_leaf_btc( )); } + for (position, opcode) in [ + (1, bitcoin::opcodes::all::OP_CHECKSIG), + (3, bitcoin::opcodes::all::OP_CHECKSIGADD), + (4, bitcoin::opcodes::all::OP_PUSHNUM_2), + (5, bitcoin::opcodes::all::OP_EQUAL), + ] { + if instructions[position] != bitcoin::script::Instruction::Op(opcode) { + return Err(SigExtractionError::UnexpectedOpcode(input_index, position)); + } + } + let pk1 = match &instructions[0] { bitcoin::blockdata::script::Instruction::PushBytes(b) if b.len() == 32 => { bitcoin::XOnlyPublicKey::from_slice(b.as_bytes()) @@ -200,7 +227,7 @@ pub fn extract_pubkeys_from_p2tr_multisig_leaf_elements( input_index: usize, ) -> Result<[elements::secp256k1_zkp::XOnlyPublicKey; 2], SigExtractionError> { let instructions = script - .instructions() + .instructions_minimal() .collect::, _>>() .map_err(|_| SigExtractionError::ParseFail(input_index))?; @@ -213,6 +240,17 @@ pub fn extract_pubkeys_from_p2tr_multisig_leaf_elements( )); } + for (position, opcode) in [ + (1, elements::opcodes::all::OP_CHECKSIG), + (3, elements::opcodes::all::OP_CHECKSIGADD), + (4, elements::opcodes::all::OP_PUSHNUM_2), + (5, elements::opcodes::all::OP_EQUAL), + ] { + if instructions[position] != elements::script::Instruction::Op(opcode) { + return Err(SigExtractionError::UnexpectedOpcode(input_index, position)); + } + } + let pk1 = match &instructions[0] { elements::script::Instruction::PushBytes(b) if b.len() == 32 => { elements::secp256k1_zkp::XOnlyPublicKey::from_slice(b) @@ -290,6 +328,94 @@ mod tests { use super::*; use simplicity::elements::secp256k1_zkp::{PublicKey as Secp256k1PublicKey, Secp256k1}; + #[test] + fn multisig_leaf_parsers_require_every_opcode_and_canonical_pushes() { + let (_, cosigner) = generate_keypair(); + let (_, user) = generate_keypair(); + let btc = p2tr_multisig_leaf_btc(&cosigner, &user); + let elements = p2tr_multisig_leaf_elements(&cosigner, &user); + assert_eq!( + extract_pubkeys_from_p2tr_multisig_leaf_btc(&btc, 0).unwrap(), + [cosigner.to_x_only_pubkey(), user.to_x_only_pubkey()], + ); + assert_eq!( + extract_pubkeys_from_p2tr_multisig_leaf_elements(&elements, 0).unwrap(), + [cosigner.to_x_only_pubkey(), user.to_x_only_pubkey()], + ); + for original in [btc.as_bytes(), elements.as_bytes()] { + for position in [33, 67, 68, 69] { + let mut changed = original.to_vec(); + changed[position] = 0x61; // OP_NOP preserves instruction count. + assert!(matches!( + extract_pubkeys_from_p2tr_multisig_leaf_btc( + &bitcoin::ScriptBuf::from_bytes(changed.clone()), + 3 + ), + Err(SigExtractionError::UnexpectedOpcode(3, _)), + )); + assert!(matches!( + extract_pubkeys_from_p2tr_multisig_leaf_elements( + &elements::Script::from(changed), + 3 + ), + Err(SigExtractionError::UnexpectedOpcode(3, _)), + )); + } + let mut nonminimal = vec![0x4c, 0x20]; + nonminimal.extend_from_slice(&original[1..]); + assert!( + extract_pubkeys_from_p2tr_multisig_leaf_btc( + &bitcoin::ScriptBuf::from_bytes(nonminimal.clone()), + 0, + ) + .is_err() + ); + assert!( + extract_pubkeys_from_p2tr_multisig_leaf_elements( + &elements::Script::from(nonminimal), + 0, + ) + .is_err() + ); + let mut invalid_key = original.to_vec(); + invalid_key[1..33].fill(0xff); + assert!( + extract_pubkeys_from_p2tr_multisig_leaf_btc( + &bitcoin::ScriptBuf::from_bytes(invalid_key.clone()), + 0, + ) + .is_err() + ); + assert!( + extract_pubkeys_from_p2tr_multisig_leaf_elements( + &elements::Script::from(invalid_key), + 0, + ) + .is_err() + ); + } + } + + #[test] + fn test_signing_commitment() { + let cmr = Cmr::from_byte_array([1; 32]); + let mut script = vec![0x52, 0x21, 0x02]; + script.extend_from_slice(&[2; 32]); + let commitment = signing_commitment(cmr, &script); + assert_eq!( + hex::encode(commitment), + "d1eeca3675cdeeb910edb4644dc13ffba0b2d8f1ee9c10133ea724f089994f57" + ); + assert_ne!( + commitment, + signing_commitment(Cmr::from_byte_array([2; 32]), &script) + ); + script[2] = 0x03; + assert_ne!(commitment, signing_commitment(cmr, &script)); + assert_ne!(signing_commitment(cmr, &[]), cmr.to_byte_array()); + assert_ne!(signing_commitment(cmr, &[]), signing_commitment(cmr, &[0])); + } + #[test] fn test_generate_keypair() { let (secret_key, public_key) = generate_keypair(); diff --git a/service/Cargo.toml b/service/Cargo.toml index 76cacac..fa7f9cd 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -12,7 +12,7 @@ log = "0.4.29" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time"] } toml = "0.9.10" tracing = "0.1" tracing-subscriber = "0.3.0" @@ -20,3 +20,8 @@ validator = { version = "0.20.0", features = ["derive"] } simplicity-lang = { workspace = true } core = { path = "../core" } + +[dev-dependencies] +http-body = "1" +tempfile = "3" +tower = { version = "0.5", features = ["util"] } diff --git a/service/README.md b/service/README.md index a17640f..69b3ad8 100644 --- a/service/README.md +++ b/service/README.md @@ -4,7 +4,22 @@ Simplicity Unchained Service is a web service that provides endpoints to interac ## Starting the Service -To start the Simplicity Unchained Servic navigate to the `service` directory and run the following command: +First provision a configuration with a unique, persistent signing key: + +```bash +umask 077 +cd service +cp config.example.toml config.toml +``` + +Set `service.private_key` in the local `config.toml` to a newly generated +32-byte secp256k1 private key (64 hex characters). Use a cryptographically secure +generator, for example `openssl rand -hex 32`, and edit the file locally without +putting the key in command-line arguments. Keep the file private and backed up; +do not commit it or put it in a Docker build context. The example's empty key +deliberately cannot start the service. + +Then start the service: ```bash cargo run --quiet -- start @@ -24,15 +39,32 @@ By default, service configuration looks for a `config.toml` file in the current [service] # Port on which the service will run port = 30431 -# secp256k1 32-byte hex-encoded private key, used for signing -# this is a demo key, do not use it anywhere else -private_key = "804622cda0d8e634317a12651d91751ceff5c081f2b5f63ef7912725c7275e5d" +# Required: replace locally with a unique, persistent 32-byte private key. +private_key = "" # Network to get metadata from: liquid or liquidtestnet elements_network = "liquidtestnet" # Bitcoin network for signature computation: bitcoin, testnet, testnet4 bitcoin_network = "testnet4" ``` +### Docker + +The image contains the service binary but no active configuration or signing key. +Provision a configuration outside the build context using the example above, +then mount it read-only: + +```bash +docker build -t simplicity-unchained-service . +docker run --rm -p 30431:30431 \ + --mount type=bind,src=/absolute/path/to/service-config.toml,dst=/app/config.toml,readonly \ + simplicity-unchained-service +``` + +Run these commands from the repository root. The host file must exist and be +readable by the container's service user (UID/GID 1000), without making the key +world-readable. Adjust the published port if you change `service.port`. +Starting the image without provisioning fails closed with a nonzero exit. + ## Endpoints The service exposes the following endpoints: @@ -53,6 +85,19 @@ The service exposes the following endpoints: `POST /simplicity-unchained/sign/psbt`: Accepts a Simplicity program and its inputs, executes it, and if successful, co-signs a 2-of-2 multisig Bitcoin transaction. +For per-spend authorization, the program must verify an approver signature over +`jet::tx_approval_hash()`. The runner derives this digest from the PSBT, all +prevouts, input index, configured Bitcoin network, and program/script commitment. +Use the [local approval workflow](../cli/README.md#bitcoin-transaction-approvals) +before requesting the signature. All inputs need `witness_utxo`; the approver +must verify those prevouts independently. + +A fixed-message signature, hash preimage, or other static condition is still +replayable across transactions. Merely providing a PSBT or using the new runner +does not convert such a policy into per-spend approval. Existing static contracts +need a new program/address to adopt the new condition. See the +[digest definition and compatibility rules](../core/README.md#bitcoin-per-spend-approvals). + **Request Body**: ```json @@ -75,7 +120,7 @@ The service exposes the following endpoints: - `user_pubkey`: The user's compressed public key in hexadecimal format - `user_leaf_hash_hex`: The user leaf hash in hexadecimal format, used to reconstruct the taproot tree - `witness`: The witness data required by the Simplicity program (optional) -- `redeem_script_hex`: The redeem script in hexadecimal format, used for SegWit v0 signature computation (optional) +- `redeem_script_hex`: Script bytes exposed to script-reading jets (optional; null or omitted means empty). Must exactly match the script supplied to `/tweak` before funding. **Success Response**: @@ -122,7 +167,7 @@ The service exposes the following endpoints: - `user_pubkey`: The user's compressed public key in hexadecimal format - `user_leaf_hash_hex`: The user leaf hash in hexadecimal format, used to reconstruct the taproot tree - `witness`: The witness data required by the Simplicity program (optional) -- `redeem_script_hex`: The redeem script in hexadecimal format, used for SegWit v0 signature computation (optional) +- `redeem_script_hex`: Script bytes exposed to script-reading jets (optional; null or omitted means empty). Must exactly match the script supplied to `/tweak` before funding. **Success Response**: @@ -146,18 +191,19 @@ The service exposes the following endpoints: ### 4. Tweak Endpoint -`POST /simplicity-unchained/tweak`: Accepts a Simplicity program, computes its CMR (commitment Merkle root), and returns the tweaked public key using taproot key tweaking. +`POST /simplicity-unchained/tweak`: Accepts a Simplicity program and script data, computes its CMR (commitment Merkle root), and returns the public key tweaked with a commitment to both. ### **Note** -In P2TR case, key, tweaked with Simplicity CMR, used as inner key. +The tweaked key is used in the cooperative multisig leaf, not as the P2TR internal key. **Request Body**: ```json { "program": "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA", - "jet_env": "elements" + "jet_env": "elements", + "redeem_script_hex": null } ``` @@ -165,6 +211,7 @@ In P2TR case, key, tweaked with Simplicity CMR, used as inner key. - `program`: The Simplicity program to compute the CMR from (must be a non-empty string) - `jet_env`: The jet environment to use, either `elements` or `bitcoin` (optional, defaults to `elements`) +- `redeem_script_hex`: Exact script bytes to commit to and later expose to script-reading jets (optional; null, omitted, or `""` means empty). **Success Response**: diff --git a/service/config.example.toml b/service/config.example.toml new file mode 100644 index 0000000..89ba48a --- /dev/null +++ b/service/config.example.toml @@ -0,0 +1,7 @@ +[service] +port = 30431 +# Required: provision a unique 32-byte secp256k1 private key as 64 hex characters. +# This deliberately empty example cannot start the signer. +private_key = "" +elements_network = "liquidtestnet" +bitcoin_network = "testnet4" diff --git a/service/config.toml b/service/config.toml deleted file mode 100644 index b0104ae..0000000 --- a/service/config.toml +++ /dev/null @@ -1,10 +0,0 @@ -[service] -# Port on which the service will run -port = 30431 -# secpr256k1 32-byte hex-encoded private key, used for signing, -# this is a demo key, do not use it anywhere else -private_key = "804622cda0d8e634317a12651d91751ceff5c081f2b5f63ef7912725c7275e5d" -# Network to get metadata from: liquid or liquidtestnet -elements_network = "liquidtestnet" -# Bitcoin network for signature computation: bitcoin, testnet, testnet4 -bitcoin_network = "testnet4" diff --git a/service/src/config.rs b/service/src/config.rs index 1eb5271..1bcdfa9 100644 --- a/service/src/config.rs +++ b/service/src/config.rs @@ -5,16 +5,16 @@ use std::path::PathBuf; pub enum Error { #[error("failed to read config file: {0}")] Io(std::io::Error), - #[error("failed to parse config file: {0}")] + #[error("failed to parse config file; check TOML syntax and required service fields")] Toml(toml::de::Error), } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct Config { pub service: ServiceConfig, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct ServiceConfig { pub port: u16, pub private_key: String, diff --git a/service/src/handlers/mod.rs b/service/src/handlers/mod.rs index 506ec72..2f4651a 100644 --- a/service/src/handlers/mod.rs +++ b/service/src/handlers/mod.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use axum::{ Json, Router, + extract::DefaultBodyLimit, + middleware, response::IntoResponse, routing::{get, post}, }; @@ -14,9 +16,24 @@ use serde::Serialize; use serde_json::json; -use simplicity::elements::secp256k1_zkp::{All, Secp256k1, SecretKey}; +use simplicity::elements::secp256k1_zkp::{All, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; +use crate::work::{MAX_BLOCKING_JOBS, MAX_BODY_BYTES}; use simplicity_unchained_core::{BitcoinNetwork, ElementsNetwork}; +use tokio::sync::Semaphore; + +// Public identity of the disclosed demo key; also reject its negation (same Taproot identity). +const DISCLOSED_DEMO_KEY: &str = "3523982d58e94be3b735731593f8225043880d53727235b566c515d24a0f7baf"; + +fn validate_signing_identity(public_key: XOnlyPublicKey) -> Result<(), String> { + if public_key.to_string() == DISCLOSED_DEMO_KEY { + return Err( + "The publicly disclosed demo signing key is not allowed; provision a new private key" + .to_string(), + ); + } + Ok(()) +} async fn version() -> impl IntoResponse { Json(json!({ @@ -36,6 +53,8 @@ pub fn routes(signer_state: SignerState) -> Router { post(sign_pset::sign_pset), ) .route("/simplicity-unchained/tweak", post(tweak::get_tweaked_key)) + .route_layer(middleware::from_fn(crate::work::request_deadline)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(signer_state) } @@ -45,6 +64,7 @@ pub struct SignerState { pub secp: Arc>, pub elements_network: ElementsNetwork, pub bitcoin_network: BitcoinNetwork, + pub work_slots: Arc, } impl SignerState { @@ -59,11 +79,19 @@ impl SignerState { let secret_key = SecretKey::from_slice(&secret_key_bytes) .map_err(|e| format!("Invalid private key: {}", e))?; + let secp = Secp256k1::new(); + validate_signing_identity( + PublicKey::from_secret_key(&secp, &secret_key) + .x_only_public_key() + .0, + )?; + Ok(Self { secret_key, - secp: Arc::new(Secp256k1::new()), + secp: Arc::new(secp), elements_network, bitcoin_network, + work_slots: Arc::new(Semaphore::new(MAX_BLOCKING_JOBS)), }) } } @@ -72,3 +100,273 @@ impl SignerState { pub struct ErrorResponse { pub error: String, } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + response::Response, + }; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use simplicity::{ + ConstructNode, Word, + bitcoin::PublicKey, + elements::secp256k1_zkp::{Keypair, Message}, + jet::Jet, + node::{CoreConstructible, WitnessConstructible}, + types, + }; + use tower::ServiceExt; + + fn test_state() -> SignerState { + SignerState::new( + &hex::encode([0xcd; 32]), + ElementsNetwork::LiquidTestnet, + BitcoinNetwork::Testnet, + ) + .unwrap() + } + + #[test] + fn disclosed_demo_identity_is_rejected_for_both_parities() { + for prefix in ["02", "03"] { + let public_key = simplicity::elements::secp256k1_zkp::PublicKey::from_str(&format!( + "{}{}", + prefix, DISCLOSED_DEMO_KEY + )) + .unwrap(); + let result = validate_signing_identity(public_key.x_only_public_key().0); + assert!( + result + .unwrap_err() + .contains("publicly disclosed demo signing key") + ); + } + assert!( + SignerState::new("", ElementsNetwork::LiquidTestnet, BitcoinNetwork::Testnet,).is_err() + ); + assert!( + SignerState::new( + &hex::encode([0; 32]), + ElementsNetwork::LiquidTestnet, + BitcoinNetwork::Testnet, + ) + .is_err() + ); + } + + pub(super) async fn post_json( + state: &SignerState, + path: &str, + body: serde_json::Value, + ) -> Response { + routes(state.clone()) + .oneshot( + Request::post(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap() + } + + pub(super) fn expensive_program() -> String { + let bytes = types::Context::with_context(|ctx| { + let mut node = Arc::::unit(&ctx); + for _ in 0..20 { + node = Arc::::comp(&node, &node).unwrap(); + } + node.finalize_types().unwrap().to_vec_without_witness() + }); + assert!(bytes.len() < 256); + STANDARD.encode(bytes) + } + + pub(super) fn script_key_program( + get_pubkey: J, + verify: J, + secret_key: &SecretKey, + ) -> (String, String) { + let program = types::Context::with_context(|ctx| { + let index = Arc::::const_word(&ctx, Word::u8(1)); + let key_jet = Arc::::jet(&ctx, &get_pubkey); + let key = Arc::::comp(&index, &key_jet).unwrap(); + let msg = Arc::::const_word(&ctx, Word::u256([0x42; 32])); + let key_msg = Arc::::pair(&key, &msg).unwrap(); + let signature = Arc::::witness(&ctx, None); + let args = Arc::::pair(&key_msg, &signature).unwrap(); + let verify_jet = Arc::::jet(&ctx, &verify); + Arc::::comp(&args, &verify_jet) + .unwrap() + .finalize_types() + .unwrap() + .to_vec_without_witness() + }); + let secp = Secp256k1::new(); + let signature = secp.sign_schnorr( + &Message::from_digest([0x42; 32]), + &Keypair::from_secret_key(&secp, secret_key), + ); + ( + STANDARD.encode(program), + STANDARD.encode(signature.as_ref()), + ) + } + + pub(super) fn key_script(state: &SignerState, secret_key: &SecretKey) -> String { + let key = + simplicity::bitcoin::secp256k1::PublicKey::from_secret_key(&*state.secp, secret_key); + format!("5221{}", hex::encode(key.serialize())) + } + + pub(super) async fn tweaked_public_key( + state: &SignerState, + program: &str, + script: Option<&str>, + jet_env: tweak::JetEnvKind, + ) -> PublicKey { + let response = post_json( + state, + "/simplicity-unchained/tweak", + json!({ + "program": program, + "jet_env": match jet_env { + tweak::JetEnvKind::Bitcoin => "bitcoin", + tweak::JetEnvKind::Elements => "elements", + }, + "redeem_script_hex": script, + }), + ) + .await; + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); + PublicKey::from_str(result["tweaked_public_key_hex"].as_str().unwrap()).unwrap() + } + + #[tokio::test] + async fn cpu_admission_is_shared_and_precedes_json_extraction() { + let state = test_state(); + let slots = [ + Arc::clone(&state.work_slots).acquire_owned().await.unwrap(), + Arc::clone(&state.work_slots).acquire_owned().await.unwrap(), + ]; + let app = routes(state.clone()); + for path in [ + "/simplicity-unchained/sign/psbt", + "/simplicity-unchained/sign/pset", + "/simplicity-unchained/tweak", + ] { + let response = app + .clone() + .oneshot(Request::post(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + let response = app + .oneshot( + Request::get("/simplicity-unchained/version") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + drop(slots); + let response = post_json( + &state, + "/simplicity-unchained/tweak", + json!({ + "program": "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA", + }), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(state.work_slots.available_permits(), MAX_BLOCKING_JOBS); + } + + #[tokio::test] + async fn request_body_limit_has_exact_boundary() { + let state = test_state(); + let mut body = json!({ + "program": "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA", + }) + .to_string(); + body.extend(std::iter::repeat_n(' ', MAX_BODY_BYTES - body.len())); + for (extra, status) in [(0, StatusCode::OK), (1, StatusCode::PAYLOAD_TOO_LARGE)] { + let response = routes(state.clone()) + .oneshot( + Request::post("/simplicity-unchained/tweak") + .header("content-type", "application/json") + .body(Body::from(format!("{}{}", body, " ".repeat(extra)))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), status); + assert_eq!(state.work_slots.available_permits(), MAX_BLOCKING_JOBS); + } + } + + #[tokio::test] + async fn malformed_requests_release_admission_slots() { + let state = test_state(); + for path in [ + "/simplicity-unchained/sign/psbt", + "/simplicity-unchained/sign/pset", + "/simplicity-unchained/tweak", + ] { + let response = post_json(&state, path, json!({})).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(state.work_slots.available_permits(), MAX_BLOCKING_JOBS); + } + } + + #[tokio::test] + async fn stalled_body_hits_request_deadline_and_releases_slot() { + use axum::body::{Bytes, HttpBody}; + use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll}, + time::Duration, + }; + + struct StalledBody; + impl HttpBody for StalledBody { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + Poll::Pending + } + } + + let state = test_state(); + let response = tokio::time::timeout( + Duration::from_secs(6), + routes(state.clone()).oneshot( + Request::post("/simplicity-unchained/tweak") + .header("content-type", "application/json") + .body(Body::new(StalledBody)) + .unwrap(), + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!(state.work_slots.available_permits(), MAX_BLOCKING_JOBS); + } +} diff --git a/service/src/handlers/sign_psbt.rs b/service/src/handlers/sign_psbt.rs index 4e18fcd..22e5a59 100644 --- a/service/src/handlers/sign_psbt.rs +++ b/service/src/handlers/sign_psbt.rs @@ -14,13 +14,16 @@ use simplicity::elements::secp256k1_zkp::Message; use serde::{Deserialize, Serialize}; -use simplicity_unchained_core::utils::{UNSPENDABLE_KEY_P2TR, p2tr_multisig_leaf_btc}; +use simplicity_unchained_core::utils::{ + UNSPENDABLE_KEY_P2TR, p2tr_multisig_leaf_btc, signing_commitment, +}; use validator::Validate; use simplicity_unchained_core::runner::SimplicityRunner; use crate::handlers::ErrorResponse; use crate::validation; +use crate::work::WorkPermit; use super::SignerState; @@ -60,22 +63,19 @@ pub struct SignPsbtResponse { pub async fn sign_psbt( State(state): State, + permit: WorkPermit, Json(request): Json, ) -> impl IntoResponse { - // Validate request using validator - if let Err(errors) = request.validate() { - let error_msg = format!("Validation failed: {}", errors); - log::error!("[400] Sign PSBT validation error: {}", error_msg); - - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { error: error_msg }), - ) - .into_response(); - } - - match sign_psbt_internal(&state, request) { - Ok(response) => { + let result = permit + .run(move || { + request + .validate() + .map_err(|e| format!("Validation failed: {}", e))?; + sign_psbt_internal(&state, request) + }) + .await; + match result { + Ok(Ok(response)) => { log::info!( "[200] Sign PSBT successful: Tweaked Public Key {}", response.public_key_hex @@ -83,11 +83,12 @@ pub async fn sign_psbt( (StatusCode::OK, Json(response)).into_response() } - Err(e) => { + Ok(Err(e)) => { log::error!("[400] Sign PSBT error: {}", e); (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })).into_response() } + Err(e) => e.into_response(), } } @@ -119,21 +120,14 @@ fn sign_psbt_internal( TapNodeHash::from_slice(&bytes).map_err(|e| e.to_string())? }; - let redeem_script = match request.redeem_script_hex { - Some(str) => { - let bytes = hex::decode(str) - .map_err(|e| format!("Failed to decode redeem script hex: {}", e))?; - simplicity::elements::Script::from(bytes) - } - None => simplicity::elements::Script::new(), - }; + let redeem_script = validation::decode_redeem_script(request.redeem_script_hex.as_deref())?; let cmr = SimplicityRunner::execute_bitcoin( &request.program, request.witness.as_deref(), request.input_index, &psbt, - redeem_script, + redeem_script.clone(), state.bitcoin_network, ) .map_err(|e| format!("Simplicity execution failed: {}", e))?; @@ -141,7 +135,10 @@ fn sign_psbt_internal( let untweaked_keypair = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key); let tweaked_keypair = untweaked_keypair.tap_tweak( &*state.secp, - Some(TapNodeHash::from_byte_array(cmr.to_byte_array())), + Some(TapNodeHash::from_byte_array(signing_commitment( + cmr, + redeem_script.as_bytes(), + ))), ); let tx = psbt @@ -216,6 +213,15 @@ fn sign_p2tr( }) .collect::>()?; + if prevouts[input_index].script_pubkey + != bitcoin::ScriptBuf::new_p2tr_tweaked(spend_info.output_key()) + { + return Err( + "Spent output does not match the program, redeem script, user key and recovery leaf" + .to_string(), + ); + } + let mut sighash_cache = SighashCache::new(tx); let sighash = sighash_cache .taproot_script_spend_signature_hash( @@ -274,6 +280,7 @@ mod tests { secp: Arc::new(Secp256k1::new()), elements_network: ElementsNetwork::LiquidTestnet, bitcoin_network: BitcoinNetwork::Testnet, + work_slots: Arc::new(tokio::sync::Semaphore::new(crate::work::MAX_BLOCKING_JOBS)), } } @@ -315,14 +322,16 @@ mod tests { state: &SignerState, user_secret_key: &SecretKey, ) -> (bitcoin::Address, TapNodeHash, bitcoin::PublicKey) { - let cosigner_pubkey = bitcoin::PublicKey::from_private_key( + let cmr = Program::::from_str(PROGRAM, None) + .unwrap() + .cmr(); + let tweaked = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key).tap_tweak( &*state.secp, - &PrivateKey { - compressed: true, - network: NetworkKind::Test, - inner: state.secret_key, - }, + Some(TapNodeHash::from_byte_array(signing_commitment(cmr, &[]))), ); + let (key, parity) = tweaked.public_parts(); + let cosigner_pubkey = + bitcoin::PublicKey::new(key.to_x_only_public_key().public_key(parity)); let user_pubkey = bitcoin::PublicKey::from_private_key( &*state.secp, &PrivateKey { @@ -392,6 +401,43 @@ mod tests { assert!(!signed_psbt.inputs[0].tap_script_sigs.is_empty()); } + #[tokio::test] + async fn high_cost_program_is_rejected_by_signing_endpoint() { + use super::super::tests::{expensive_program, post_json}; + let state = create_test_signer_state(); + let user_secret = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let (address, recovery, user) = create_test_p2tr_setup(&state, &user_secret); + let psbt = create_test_psbt(create_test_transaction(), address.script_pubkey()); + let response = tokio::time::timeout( + std::time::Duration::from_secs(1), + post_json( + &state, + "/simplicity-unchained/sign/psbt", + serde_json::json!({ + "psbt_hex": hex::encode(psbt.serialize()), + "input_index": 0, + "program": expensive_program(), + "witness": "", + "user_pubkey": hex::encode(user.to_bytes()), + "user_leaf_hash_hex": hex::encode(recovery.to_byte_array()), + }), + ), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + body["error"] + .as_str() + .unwrap() + .contains("exceeds maximum 10000000 milliweight") + ); + } + #[test] fn test_sign_psbt_internal_invalid_hex() { let state = create_test_signer_state(); @@ -510,7 +556,7 @@ mod tests { let untweaked = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key); let tweaked = untweaked.tap_tweak( &*state.secp, - Some(TapNodeHash::from_byte_array(cmr.to_byte_array())), + Some(TapNodeHash::from_byte_array(signing_commitment(cmr, &[]))), ); let (cosigner_xonly, _) = tweaked.public_parts(); @@ -522,6 +568,278 @@ mod tests { assert!(has_cosigner_sig); } + #[tokio::test] + async fn test_script_key_substitution_is_rejected() { + use super::super::tests::{key_script, script_key_program, tweaked_public_key}; + use super::super::tweak::JetEnvKind; + use simplicity::jet::Core; + + let state = create_test_signer_state(); + let guardian = SecretKey::from_slice(&[0x11; 32]).unwrap(); + let attacker = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let (program, witness) = script_key_program( + CoreExtension::GetPubkeyFromScript, + CoreExtension::Core(Core::Bip0340Verify), + &guardian, + ); + let (attacker_program, attacker_witness) = script_key_program( + CoreExtension::GetPubkeyFromScript, + CoreExtension::Core(Core::Bip0340Verify), + &attacker, + ); + assert_eq!(program, attacker_program); + let script = key_script(&state, &guardian); + let attacker_script = key_script(&state, &attacker); + let cosigner = + tweaked_public_key(&state, &program, Some(&script), JetEnvKind::Bitcoin).await; + let (_, recovery, user) = create_test_p2tr_setup(&state, &attacker); + let (address, spend_info) = + generate_p2tr_address_btc(&cosigner, &user, recovery, bitcoin::Network::Testnet) + .unwrap(); + let tx = create_test_transaction(); + let psbt = create_test_psbt(tx.clone(), address.script_pubkey()); + let request = |script: Option<&str>, witness: &str| { + let mut request = + create_test_sign_request(hex::encode(psbt.serialize()), &user, recovery); + request.program = program.clone(); + request.witness = Some(witness.to_string()); + request.redeem_script_hex = script.map(str::to_string); + request + }; + let result = sign_psbt_internal(&state, request(Some(&script), &witness)).unwrap(); + let signed = Psbt::deserialize(&hex::decode(result.psbt_hex).unwrap()).unwrap(); + let ((signing_key, _), signature) = + signed.inputs[0].tap_script_sigs.first_key_value().unwrap(); + assert_eq!(*signing_key, cosigner.inner.x_only_public_key().0); + let (control, (leaf, _)) = signed.inputs[0].tap_scripts.first_key_value().unwrap(); + assert!(control.verify_taproot_commitment( + &*state.secp, + spend_info.output_key().to_x_only_public_key(), + leaf, + )); + let sighash = SighashCache::new(&tx) + .taproot_script_spend_signature_hash( + 0, + &Prevouts::All(&[psbt.inputs[0].witness_utxo.clone().unwrap()]), + ScriptPath::with_defaults(leaf), + TapSighashType::Default, + ) + .unwrap(); + state + .secp + .verify_schnorr( + &signature.signature, + &Message::from_digest(sighash.to_byte_array()), + signing_key, + ) + .unwrap(); + assert!( + sign_psbt_internal(&state, request(Some(&script), &attacker_witness)) + .unwrap_err() + .contains("Simplicity execution failed") + ); + assert!( + sign_psbt_internal(&state, request(Some(&attacker_script), &attacker_witness)) + .unwrap_err() + .contains("Spent output does not match") + ); + let changed_opcode = format!("51{}", &script[2..]); + assert!( + sign_psbt_internal(&state, request(Some(&changed_opcode), &witness)) + .unwrap_err() + .contains("Spent output does not match") + ); + assert!(sign_psbt_internal(&state, request(None, &attacker_witness)).is_err()); + // A signature over a fixed message remains a reusable policy, by design. + let mut changed_tx = psbt.clone(); + changed_tx.unsigned_tx.output[0].value = bitcoin::Amount::from_sat(40_000); + let mut static_replay = request(Some(&script), &witness); + static_replay.psbt_hex = hex::encode(changed_tx.serialize()); + assert!(sign_psbt_internal(&state, static_replay).is_ok()); + } + + #[tokio::test] + async fn transaction_approval_cannot_be_replayed_for_a_different_spend() { + use super::super::tests::{post_json, tweaked_public_key}; + use super::super::tweak::JetEnvKind; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use simplicity::{ + ConstructNode, Word, + jet::Core, + node::{CoreConstructible, WitnessConstructible}, + types, + }; + use simplicity_unchained_core::runner::bitcoin_execution_environment; + + let state = create_test_signer_state(); + let guardian = SecretKey::from_slice(&[0x11; 32]).unwrap(); + let guardian_pair = UntweakedKeypair::from_secret_key(&*state.secp, &guardian); + let guardian_key = guardian_pair.x_only_public_key().0; + let program = types::Context::with_context(|ctx| { + let key = Arc::::const_word(&ctx, Word::u256(guardian_key.serialize())); + let hash = Arc::::jet(&ctx, &CoreExtension::TxApprovalHash); + let key_msg = Arc::::pair(&key, &hash).unwrap(); + let signature = Arc::::witness(&ctx, None); + let args = Arc::::pair(&key_msg, &signature).unwrap(); + let verify = Arc::::jet(&ctx, &CoreExtension::Core(Core::Bip0340Verify)); + STANDARD.encode( + Arc::::comp(&args, &verify) + .unwrap() + .finalize_types() + .unwrap() + .to_vec_without_witness(), + ) + }); + let cmr = Program::::from_str(&program, None) + .unwrap() + .cmr(); + let user_secret = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let (_, recovery, user) = create_test_p2tr_setup(&state, &user_secret); + let cosigner = tweaked_public_key(&state, &program, None, JetEnvKind::Bitcoin).await; + let (address, spend_info) = + generate_p2tr_address_btc(&cosigner, &user, recovery, bitcoin::Network::Testnet) + .unwrap(); + let psbt = create_test_psbt(create_test_transaction(), address.script_pubkey()); + let approval = |psbt: &Psbt| { + let digest = bitcoin_execution_environment( + psbt, + 0, + cmr, + &simplicity::elements::Script::new(), + state.bitcoin_network, + ) + .unwrap() + .approval_hash; + STANDARD.encode( + state + .secp + .sign_schnorr(&Message::from_digest(digest), &guardian_pair) + .as_ref(), + ) + }; + let witness = approval(&psbt); + let body = |psbt: &Psbt, witness: &str| { + serde_json::json!({ + "psbt_hex": hex::encode(psbt.serialize()), + "input_index": 0, + "program": program, + "witness": witness, + "user_pubkey": hex::encode(user.to_bytes()), + "user_leaf_hash_hex": hex::encode(recovery.to_byte_array()), + }) + }; + let response = post_json( + &state, + "/simplicity-unchained/sign/psbt", + body(&psbt, &witness), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let result: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let signed = + Psbt::deserialize(&hex::decode(result["psbt_hex"].as_str().unwrap()).unwrap()).unwrap(); + let ((signing_key, _), signature) = + signed.inputs[0].tap_script_sigs.first_key_value().unwrap(); + let (control, (leaf, _)) = signed.inputs[0].tap_scripts.first_key_value().unwrap(); + assert!(control.verify_taproot_commitment( + &*state.secp, + spend_info.output_key().to_x_only_public_key(), + leaf, + )); + let sighash = SighashCache::new(&psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + 0, + &Prevouts::All(&[psbt.inputs[0].witness_utxo.clone().unwrap()]), + ScriptPath::with_defaults(leaf), + TapSighashType::Default, + ) + .unwrap(); + state + .secp + .verify_schnorr( + &signature.signature, + &Message::from_digest(sighash.to_byte_array()), + signing_key, + ) + .unwrap(); + + let mut changed_output = psbt.clone(); + changed_output.unsigned_tx.output[0].value = bitcoin::Amount::from_sat(40_000); + let mut changed_locktime = psbt.clone(); + changed_locktime.unsigned_tx.lock_time = bitcoin::absolute::LockTime::from_consensus(1); + let mut changed_prevout = psbt.clone(); + changed_prevout.inputs[0] + .witness_utxo + .as_mut() + .unwrap() + .value = bitcoin::Amount::from_sat(99_999); + for changed in [&changed_output, &changed_locktime, &changed_prevout] { + let response = post_json( + &state, + "/simplicity-unchained/sign/psbt", + body(changed, &witness), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let result: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert!( + result["error"] + .as_str() + .unwrap() + .contains("Simplicity execution failed") + ); + } + let mut other_network = state.clone(); + other_network.bitcoin_network = BitcoinNetwork::Testnet4; + let response = post_json( + &other_network, + "/simplicity-unchained/sign/psbt", + body(&psbt, &witness), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let fresh_witness = approval(&changed_output); + let response = post_json( + &state, + "/simplicity-unchained/sign/psbt", + body(&changed_output, &fresh_witness), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn test_legacy_cmr_only_output_is_rejected() { + let state = create_test_signer_state(); + let user_secret = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let (_, recovery, user) = create_test_p2tr_setup(&state, &user_secret); + let cmr = Program::::from_str(PROGRAM, None) + .unwrap() + .cmr(); + let legacy = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key).tap_tweak( + &*state.secp, + Some(TapNodeHash::from_byte_array(cmr.to_byte_array())), + ); + let (key, parity) = legacy.public_parts(); + let cosigner = bitcoin::PublicKey::new(key.to_x_only_public_key().public_key(parity)); + let (address, _) = + generate_p2tr_address_btc(&cosigner, &user, recovery, bitcoin::Network::Testnet) + .unwrap(); + let psbt = create_test_psbt(create_test_transaction(), address.script_pubkey()); + let request = create_test_sign_request(hex::encode(psbt.serialize()), &user, recovery); + assert!( + sign_psbt_internal(&state, request) + .unwrap_err() + .contains("Spent output does not match") + ); + } + #[test] fn test_sign_psbt_request_validation() { let state = create_test_signer_state(); diff --git a/service/src/handlers/sign_pset.rs b/service/src/handlers/sign_pset.rs index 8c8ae79..0c53006 100644 --- a/service/src/handlers/sign_pset.rs +++ b/service/src/handlers/sign_pset.rs @@ -21,12 +21,13 @@ use serde::{Deserialize, Serialize}; use simplicity_unchained_core::{ runner::SimplicityRunner, - utils::{UNSPENDABLE_KEY_P2TR, p2tr_multisig_leaf_elements}, + utils::{UNSPENDABLE_KEY_P2TR, p2tr_multisig_leaf_elements, signing_commitment}, }; use validator::Validate; use crate::handlers::ErrorResponse; use crate::validation; +use crate::work::WorkPermit; use super::SignerState; @@ -67,22 +68,19 @@ pub struct SignPsetResponse { pub async fn sign_pset( State(state): State, + permit: WorkPermit, Json(request): Json, ) -> impl IntoResponse { - // Validate request using validator - if let Err(errors) = request.validate() { - let error_msg = format!("Validation failed: {}", errors); - log::error!("[400] Sign PSET validation error: {}", error_msg); - - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { error: error_msg }), - ) - .into_response(); - } - - match sign_pset_internal(&state, request) { - Ok(response) => { + let result = permit + .run(move || { + request + .validate() + .map_err(|e| format!("Validation failed: {}", e))?; + sign_pset_internal(&state, request) + }) + .await; + match result { + Ok(Ok(response)) => { log::info!( "[200] Sign PSET successful: Tweaked Public Key {}", response.public_key_hex @@ -90,11 +88,12 @@ pub async fn sign_pset( (StatusCode::OK, Json(response)).into_response() } - Err(e) => { + Ok(Err(e)) => { log::error!("[400] Sign PSET error: {}", e); (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })).into_response() } + Err(e) => e.into_response(), } } fn sign_pset_internal( @@ -129,21 +128,14 @@ fn sign_pset_internal( elements::hashes::sha256::Hash::from_byte_array(bytes) }; - let redeem_script = match request.redeem_script_hex { - Some(str) => { - let bytes = hex::decode(str) - .map_err(|e| format!("Failed to decode redeem script hex: {}", e))?; - simplicity::elements::Script::from(bytes) - } - None => simplicity::elements::Script::new(), - }; + let redeem_script = validation::decode_redeem_script(request.redeem_script_hex.as_deref())?; let cmr = SimplicityRunner::execute_elements( &request.program, request.witness.as_deref(), request.input_index, &pset, - redeem_script, + redeem_script.clone(), state.elements_network, ) .map_err(|e| format!("Simplicity execution failed: {}", e))?; @@ -151,7 +143,10 @@ fn sign_pset_internal( let untweaked_keypair = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key); let tweaked_keypair = untweaked_keypair.tap_tweak( &*state.secp, - Some(TapNodeHash::from_byte_array(cmr.to_byte_array())), + Some(TapNodeHash::from_byte_array(signing_commitment( + cmr, + redeem_script.as_bytes(), + ))), ); let tx = pset @@ -230,6 +225,15 @@ fn sign_p2tr( }) .collect::>()?; + if prevouts[input_index].script_pubkey + != elements::Script::new_v1_p2tr_tweaked(spend_info.output_key()) + { + return Err( + "Spent output does not match the program, redeem script, user key and recovery leaf" + .to_string(), + ); + } + let mut sighash_cache = SighashCache::new(tx); let sighash = sighash_cache .taproot_script_spend_signature_hash( @@ -276,7 +280,8 @@ mod tests { }, }; use simplicity_unchained_core::{ - BitcoinNetwork, ElementsNetwork, utils::generate_p2tr_address_elements, + BitcoinNetwork, ElementsNetwork, jets::elements::ElementsExtension, program::Program, + utils::generate_p2tr_address_elements, }; use std::str::FromStr; @@ -289,6 +294,7 @@ mod tests { secp: Arc::new(Secp256k1::new()), elements_network: ElementsNetwork::LiquidTestnet, bitcoin_network: BitcoinNetwork::Testnet, + work_slots: Arc::new(tokio::sync::Semaphore::new(crate::work::MAX_BLOCKING_JOBS)), } } @@ -347,14 +353,15 @@ mod tests { elements::hashes::sha256::Hash, bitcoin::PublicKey, ) { - let cosigner_pubkey = PublicKey::from_private_key( + let cmr = Program::::from_str(PROGRAM, None) + .unwrap() + .cmr(); + let tweaked = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key).tap_tweak( &*state.secp, - &PrivateKey { - compressed: true, - network: NetworkKind::Test, - inner: state.secret_key, - }, + Some(TapNodeHash::from_byte_array(signing_commitment(cmr, &[]))), ); + let (key, parity) = tweaked.public_parts(); + let cosigner_pubkey = PublicKey::new(key.into_inner().public_key(parity)); let user_pubkey = PublicKey::from_private_key( &*state.secp, &PrivateKey { @@ -428,6 +435,47 @@ mod tests { assert_eq!(sig_bytes.len(), 64); } + #[tokio::test] + async fn high_cost_program_is_rejected_by_signing_endpoint() { + use super::super::tests::{expensive_program, post_json}; + let state = create_test_signer_state(); + let user_secret = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let (address, recovery, user) = create_test_p2tr_setup(&state, &user_secret); + let pset = create_test_pset( + create_test_transaction(), + address.script_pubkey(), + elements::confidential::Value::Explicit(100_000), + ); + let response = tokio::time::timeout( + std::time::Duration::from_secs(1), + post_json( + &state, + "/simplicity-unchained/sign/pset", + serde_json::json!({ + "pset_hex": hex::encode(elements::encode::serialize(&pset)), + "input_index": 0, + "program": expensive_program(), + "witness": "", + "user_pubkey": hex::encode(user.to_bytes()), + "user_leaf_hash_hex": hex::encode(recovery.to_byte_array()), + }), + ), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + body["error"] + .as_str() + .unwrap() + .contains("exceeds maximum 10000000 milliweight") + ); + } + #[test] fn test_sign_pset_internal_invalid_hex() { let state = create_test_signer_state(); @@ -449,6 +497,139 @@ mod tests { assert!(result.unwrap_err().contains("Failed to decode PSET hex")); } + #[tokio::test] + async fn test_script_key_substitution_is_rejected() { + use super::super::tests::{key_script, script_key_program, tweaked_public_key}; + use super::super::tweak::JetEnvKind; + use simplicity::jet::Elements; + + let state = create_test_signer_state(); + let guardian = SecretKey::from_slice(&[0x11; 32]).unwrap(); + let attacker = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let (program, witness) = script_key_program( + ElementsExtension::GetPubkeyFromScript, + ElementsExtension::Elements(Elements::Bip0340Verify), + &guardian, + ); + let (attacker_program, attacker_witness) = script_key_program( + ElementsExtension::GetPubkeyFromScript, + ElementsExtension::Elements(Elements::Bip0340Verify), + &attacker, + ); + assert_eq!(program, attacker_program); + let script = key_script(&state, &guardian); + let attacker_script = key_script(&state, &attacker); + let cosigner = + tweaked_public_key(&state, &program, Some(&script), JetEnvKind::Elements).await; + let (_, recovery, user) = create_test_p2tr_setup(&state, &attacker); + let (address, spend_info) = generate_p2tr_address_elements( + &cosigner, + &user, + recovery, + &elements::AddressParams::LIQUID_TESTNET, + ) + .unwrap(); + let tx = create_test_transaction(); + let pset = create_test_pset( + tx, + address.script_pubkey(), + elements::confidential::Value::Explicit(100_000), + ); + let request = |script: Option<&str>, witness: &str| { + let mut request = create_test_sign_request( + hex::encode(elements::encode::serialize(&pset)), + &user, + recovery, + ); + request.program = program.clone(); + request.witness = Some(witness.to_string()); + request.redeem_script_hex = script.map(str::to_string); + request + }; + let result = sign_pset_internal(&state, request(Some(&script), &witness)).unwrap(); + let signed: Pset = deserialize(&hex::decode(result.pset_hex).unwrap()).unwrap(); + let ((signing_key, _), signature) = signed.inputs()[0] + .tap_script_sigs + .first_key_value() + .unwrap(); + assert_eq!(*signing_key, cosigner.inner.x_only_public_key().0); + let (control, (leaf, _)) = signed.inputs()[0].tap_scripts.first_key_value().unwrap(); + assert!(control.verify_taproot_commitment(&*state.secp, &spend_info.output_key(), leaf,)); + let tx = pset.extract_tx().unwrap(); + let sighash = SighashCache::new(&tx) + .taproot_script_spend_signature_hash( + 0, + &Prevouts::All(&[pset.inputs()[0].witness_utxo.clone().unwrap()]), + ScriptPath::with_defaults(leaf), + SchnorrSighashType::Default, + state.elements_network.genesis_hash(), + ) + .unwrap(); + state + .secp + .verify_schnorr( + &signature.sig, + &Message::from_digest(sighash.to_byte_array()), + signing_key, + ) + .unwrap(); + assert!( + sign_pset_internal(&state, request(Some(&script), &attacker_witness)) + .unwrap_err() + .contains("Simplicity execution failed") + ); + assert!( + sign_pset_internal(&state, request(Some(&attacker_script), &attacker_witness)) + .unwrap_err() + .contains("Spent output does not match") + ); + let changed_opcode = format!("51{}", &script[2..]); + assert!( + sign_pset_internal(&state, request(Some(&changed_opcode), &witness)) + .unwrap_err() + .contains("Spent output does not match") + ); + assert!(sign_pset_internal(&state, request(None, &attacker_witness)).is_err()); + } + + #[test] + fn test_legacy_cmr_only_output_is_rejected() { + let state = create_test_signer_state(); + let user_secret = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let (_, recovery, user) = create_test_p2tr_setup(&state, &user_secret); + let cmr = Program::::from_str(PROGRAM, None) + .unwrap() + .cmr(); + let legacy = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key).tap_tweak( + &*state.secp, + Some(TapNodeHash::from_byte_array(cmr.to_byte_array())), + ); + let (key, parity) = legacy.public_parts(); + let cosigner = PublicKey::new(key.into_inner().public_key(parity)); + let (address, _) = generate_p2tr_address_elements( + &cosigner, + &user, + recovery, + &elements::AddressParams::LIQUID_TESTNET, + ) + .unwrap(); + let pset = create_test_pset( + create_test_transaction(), + address.script_pubkey(), + elements::confidential::Value::Explicit(100_000), + ); + let request = create_test_sign_request( + hex::encode(elements::encode::serialize(&pset)), + &user, + recovery, + ); + assert!( + sign_pset_internal(&state, request) + .unwrap_err() + .contains("Spent output does not match") + ); + } + #[test] fn test_sign_pset_internal_invalid_pset_data() { let state = create_test_signer_state(); diff --git a/service/src/handlers/tweak.rs b/service/src/handlers/tweak.rs index c31e76b..7c53fe8 100644 --- a/service/src/handlers/tweak.rs +++ b/service/src/handlers/tweak.rs @@ -13,8 +13,11 @@ use validator::Validate; use simplicity_unchained_core::jets::bitcoin::CoreExtension; use simplicity_unchained_core::jets::elements::ElementsExtension; use simplicity_unchained_core::program::Program; +use simplicity_unchained_core::utils::signing_commitment; use crate::handlers::{ErrorResponse, SignerState}; +use crate::validation; +use crate::work::WorkPermit; #[derive(Debug, Deserialize)] #[serde(rename_all = "lowercase")] @@ -28,6 +31,8 @@ pub struct TweakRequest { #[validate(length(min = 1))] pub program: String, pub jet_env: Option, + #[validate(custom(function = "validation::validate_redeem_script"))] + pub redeem_script_hex: Option, } #[derive(Debug, Serialize)] @@ -38,22 +43,19 @@ pub struct TweakResponse { pub async fn get_tweaked_key( State(state): State, + permit: WorkPermit, Json(request): Json, ) -> impl IntoResponse { - // Validate request using validator - if let Err(errors) = request.validate() { - let error_msg = format!("Validation failed: {}", errors); - log::error!("[400] Tweak validation error: {}", error_msg); - - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { error: error_msg }), - ) - .into_response(); - } - - match get_tweaked_key_internal(&state, request) { - Ok(response) => { + let result = permit + .run(move || { + request + .validate() + .map_err(|e| format!("Validation failed: {}", e))?; + get_tweaked_key_internal(&state, request) + }) + .await; + match result { + Ok(Ok(response)) => { log::info!( "[200] Tweak successful: CMR {}, Tweaked Public Key {}", response.cmr_hex, @@ -62,11 +64,12 @@ pub async fn get_tweaked_key( (StatusCode::OK, Json(response)).into_response() } - Err(e) => { + Ok(Err(e)) => { log::error!("[400] Tweak error: {}", e); (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })).into_response() } + Err(e) => e.into_response(), } } @@ -92,7 +95,7 @@ fn get_tweaked_key_bitcoin( program.commit_prog().cmr() }; - // Create untweaked keypair and tweak it with the CMR + let redeem_script = validation::decode_redeem_script(request.redeem_script_hex.as_deref())?; let untweaked_keypair = bitcoin::key::UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key); @@ -100,7 +103,7 @@ fn get_tweaked_key_bitcoin( untweaked_keypair, &*state.secp, Some(bitcoin::taproot::TapNodeHash::from_byte_array( - cmr.to_byte_array(), + signing_commitment(cmr, redeem_script.as_bytes()), )), ); @@ -128,14 +131,14 @@ fn get_tweaked_key_elements( program.commit_prog().cmr() }; - // Create untweaked keypair and tweak it with the CMR + let redeem_script = validation::decode_redeem_script(request.redeem_script_hex.as_deref())?; let untweaked_keypair = elements::schnorr::UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key); let tweaked_keypair = untweaked_keypair.tap_tweak( &*state.secp, Some(elements::taproot::TapNodeHash::from_byte_array( - cmr.to_byte_array(), + signing_commitment(cmr, redeem_script.as_bytes()), )), ); @@ -166,6 +169,7 @@ mod tests { secp: Arc::new(Secp256k1::new()), elements_network: ElementsNetwork::LiquidTestnet, bitcoin_network: BitcoinNetwork::Testnet, + work_slots: Arc::new(tokio::sync::Semaphore::new(crate::work::MAX_BLOCKING_JOBS)), } } @@ -176,6 +180,7 @@ mod tests { let request = TweakRequest { program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), jet_env: None, + redeem_script_hex: None, }; let result = get_tweaked_key_internal(&state, request); @@ -199,6 +204,7 @@ mod tests { let request = TweakRequest { program: "invalid_program".to_string(), jet_env: None, + redeem_script_hex: None, }; let result = get_tweaked_key_internal(&state, request); @@ -212,6 +218,7 @@ mod tests { let valid_request = TweakRequest { program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), jet_env: None, + redeem_script_hex: None, }; assert!(valid_request.validate().is_ok()); @@ -219,6 +226,7 @@ mod tests { let invalid_request = TweakRequest { program: "".to_string(), jet_env: None, + redeem_script_hex: None, }; assert!(invalid_request.validate().is_err()); } @@ -234,6 +242,7 @@ mod tests { let request = TweakRequest { program: program.clone(), jet_env: None, + redeem_script_hex: None, }; let result = get_tweaked_key_internal(&state, request).unwrap(); @@ -246,4 +255,39 @@ mod tests { let pubkey_bytes = hex::decode(&result.tweaked_public_key_hex).unwrap(); assert_eq!(pubkey_bytes.len(), 33); // Compressed public key } + + #[test] + fn test_tweak_binds_script_in_both_environments() { + let state = create_test_signer_state(); + for bitcoin in [false, true] { + let request = |script: Option<&str>| TweakRequest { + program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), + jet_env: Some(if bitcoin { + JetEnvKind::Bitcoin + } else { + JetEnvKind::Elements + }), + redeem_script_hex: script.map(str::to_string), + }; + let empty = get_tweaked_key_internal(&state, request(None)).unwrap(); + let explicit_empty = get_tweaked_key_internal(&state, request(Some(""))).unwrap(); + assert_eq!( + empty.tweaked_public_key_hex, + explicit_empty.tweaked_public_key_hex + ); + let first = get_tweaked_key_internal(&state, request(Some("52"))).unwrap(); + let second = get_tweaked_key_internal(&state, request(Some("51"))).unwrap(); + assert_eq!(first.cmr_hex, second.cmr_hex); + assert_eq!(first.cmr_hex, empty.cmr_hex); + assert_ne!(first.tweaked_public_key_hex, second.tweaked_public_key_hex); + assert_ne!(first.tweaked_public_key_hex, empty.tweaked_public_key_hex); + assert!(request(Some("21")).validate().is_err()); + assert!(request(Some("zz")).validate().is_err()); + assert!( + get_tweaked_key_internal(&state, request(Some("zz"))) + .unwrap_err() + .contains("Failed to decode redeem script hex") + ); + } + } } diff --git a/service/src/main.rs b/service/src/main.rs index 06f4f80..0caa8db 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -2,12 +2,14 @@ mod cli; mod config; mod handlers; mod validation; +mod work; use cli::{Cli, Commands}; use config::Config; use simplicity_unchained_core::{BitcoinNetwork, ElementsNetwork}; use std::path::PathBuf; +use std::process::ExitCode; use std::str::FromStr; use axum::Router; @@ -18,72 +20,51 @@ use tokio::net::TcpListener; use crate::handlers::SignerState; #[tokio::main] -async fn main() { +async fn main() -> ExitCode { tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); let cli = Cli::parse(); - match cli.command { - Commands::Start { config } => { - start(config).await; + let result = match cli.command { + Commands::Start { config } => start(config).await, + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + error!("{}", e); + ExitCode::FAILURE } } } -async fn start(config_path: PathBuf) { - let config = match Config::from_file(config_path) { - Ok(config) => config, - Err(e) => { - error!("Failed to load config: {}", e); - return; - } - }; +async fn start(config_path: PathBuf) -> Result<(), String> { + let config = + Config::from_file(config_path).map_err(|e| format!("Failed to load config: {}", e))?; - let elements_network = match ElementsNetwork::from_str(&config.service.elements_network) { - Ok(network) => network, - Err(e) => { - error!("Failed to parse network: {}", e); - return; - } - }; + let elements_network = ElementsNetwork::from_str(&config.service.elements_network) + .map_err(|e| format!("Failed to parse Elements network: {}", e))?; - let bitcoin_network = match BitcoinNetwork::from_str(&config.service.bitcoin_network) { - Ok(network) => network, - Err(e) => { - error!("Failed to parse bitcoin network: {}", e); - return; - } - }; + let bitcoin_network = BitcoinNetwork::from_str(&config.service.bitcoin_network) + .map_err(|e| format!("Failed to parse Bitcoin network: {}", e))?; - // Initialize signer state from config - let signer_state = match SignerState::new( + let signer_state = SignerState::new( &config.service.private_key, elements_network, bitcoin_network, - ) { - Ok(state) => state, - Err(e) => { - error!("Failed to initialize signer state: {}", e); - return; - } - }; + ) + .map_err(|e| format!("Failed to initialize signer state: {}", e))?; let app = Router::new().merge(handlers::routes(signer_state)); let bind_addr = format!("0.0.0.0:{}", config.service.port); + let listener = TcpListener::bind(&bind_addr) + .await + .map_err(|e| format!("Failed to bind to {}: {}", bind_addr, e))?; info!("Starting service on {}...", bind_addr); - let listener = match TcpListener::bind(&bind_addr).await { - Ok(l) => l, - Err(e) => { - error!("Failed to bind to {}: {}", bind_addr, e); - return; - } - }; - - if let Err(e) = axum::serve(listener, app).await { - error!("Server error: {}", e); - } + axum::serve(listener, app) + .await + .map_err(|e| format!("Server error: {}", e)) } diff --git a/service/src/validation.rs b/service/src/validation.rs index d29811b..3b0cd29 100644 --- a/service/src/validation.rs +++ b/service/src/validation.rs @@ -23,3 +23,12 @@ pub fn validate_redeem_script(value: &str) -> Result<(), ValidationError> { Ok(()) } + +pub fn decode_redeem_script(value: Option<&str>) -> Result { + let bytes = value + .map(hex::decode) + .transpose() + .map_err(|e| format!("Failed to decode redeem script hex: {}", e))? + .unwrap_or_default(); + Ok(simplicity::elements::Script::from(bytes)) +} diff --git a/service/src/work.rs b/service/src/work.rs new file mode 100644 index 0000000..3c21c2a --- /dev/null +++ b/service/src/work.rs @@ -0,0 +1,204 @@ +use std::{sync::Arc, time::Duration}; + +use axum::{ + Json, + extract::{FromRequestParts, Request}, + http::{StatusCode, request::Parts}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError}; + +use crate::handlers::{ErrorResponse, SignerState}; + +pub(crate) const MAX_BLOCKING_JOBS: usize = 2; +pub(crate) const MAX_BODY_BYTES: usize = 2 * 1024 * 1024; +const WORK_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) struct WorkPermit(OwnedSemaphorePermit); + +pub(crate) async fn request_deadline(request: Request, next: Next) -> Response { + match tokio::time::timeout(WORK_TIMEOUT, next.run(request)).await { + Ok(response) => response, + Err(_) => WorkError::TimedOut.into_response(), + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum WorkError { + #[error("CPU work admission unavailable: {0}")] + Unavailable(TryAcquireError), + #[error("CPU work exceeded the response deadline")] + TimedOut, + #[error("CPU worker failed: {0}")] + WorkerFailed(tokio::task::JoinError), +} + +impl IntoResponse for WorkError { + fn into_response(self) -> Response { + let (status, message) = match &self { + Self::Unavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "Service busy; retry later"), + Self::TimedOut => (StatusCode::GATEWAY_TIMEOUT, "Request processing timed out"), + Self::WorkerFailed(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Request processing failed", + ), + }; + log::error!("[{}] {}", status.as_u16(), self); + ( + status, + Json(ErrorResponse { + error: message.to_string(), + }), + ) + .into_response() + } +} + +impl FromRequestParts for WorkPermit { + type Rejection = WorkError; + + async fn from_request_parts( + _parts: &mut Parts, + state: &SignerState, + ) -> Result { + Self::acquire(Arc::clone(&state.work_slots)) + } +} + +impl WorkPermit { + fn acquire(slots: Arc) -> Result { + slots + .try_acquire_owned() + .map(Self) + .map_err(WorkError::Unavailable) + } + + pub(crate) async fn run( + self, + job: impl FnOnce() -> T + Send + 'static, + ) -> Result { + self.run_with_timeout(WORK_TIMEOUT, job).await + } + + async fn run_with_timeout( + self, + timeout: Duration, + job: impl FnOnce() -> T + Send + 'static, + ) -> Result { + let mut task = tokio::task::spawn_blocking(move || { + // A response timeout cannot stop running blocking work. Keep its slot until it exits. + let _permit = self.0; + job() + }); + match tokio::time::timeout(timeout, &mut task).await { + Ok(result) => result.map_err(WorkError::WorkerFailed), + Err(_) => { + task.abort(); + Err(WorkError::TimedOut) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use tokio::sync::oneshot; + + #[test] + fn admission_never_queues() { + let slots = Arc::new(Semaphore::new(MAX_BLOCKING_JOBS)); + let first = WorkPermit::acquire(Arc::clone(&slots)).unwrap(); + let second = WorkPermit::acquire(Arc::clone(&slots)).unwrap(); + assert!(matches!( + WorkPermit::acquire(Arc::clone(&slots)), + Err(WorkError::Unavailable(_)) + )); + drop(first); + assert!(WorkPermit::acquire(Arc::clone(&slots)).is_ok()); + drop(second); + assert_eq!(slots.available_permits(), MAX_BLOCKING_JOBS); + } + + #[tokio::test(flavor = "current_thread")] + async fn blocking_job_does_not_block_the_async_worker() { + let slots = Arc::new(Semaphore::new(1)); + let permit = WorkPermit::acquire(Arc::clone(&slots)).unwrap(); + let (started, ready) = oneshot::channel(); + let (release, wait) = mpsc::channel(); + let job = tokio::spawn(permit.run(move || { + started.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(2)).unwrap(); + 42 + })); + tokio::time::timeout(Duration::from_secs(1), ready) + .await + .unwrap() + .unwrap(); + assert_eq!(slots.available_permits(), 0); + tokio::time::timeout(Duration::from_millis(100), tokio::task::yield_now()) + .await + .unwrap(); + release.send(()).unwrap(); + assert_eq!(job.await.unwrap().unwrap(), 42); + assert_eq!(slots.available_permits(), 1); + } + + #[tokio::test] + async fn timeout_and_request_cancellation_keep_running_work_admitted() { + for cancel in [false, true] { + let slots = Arc::new(Semaphore::new(1)); + let permit = WorkPermit::acquire(Arc::clone(&slots)).unwrap(); + let (started, ready) = oneshot::channel(); + let (release, wait) = mpsc::channel(); + let job = tokio::spawn(permit.run_with_timeout( + Duration::from_millis(100), + move || { + started.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(2)).unwrap(); + }, + )); + tokio::time::timeout(Duration::from_secs(1), ready) + .await + .unwrap() + .unwrap(); + if cancel { + job.abort(); + assert!(job.await.unwrap_err().is_cancelled()); + } else { + let error = job.await.unwrap().unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::GATEWAY_TIMEOUT); + } + assert!(matches!( + WorkPermit::acquire(Arc::clone(&slots)), + Err(WorkError::Unavailable(_)) + )); + release.send(()).unwrap(); + let returned = + tokio::time::timeout(Duration::from_secs(1), Arc::clone(&slots).acquire_owned()) + .await + .unwrap() + .unwrap(); + drop(returned); + assert_eq!(slots.available_permits(), 1); + } + } + + #[tokio::test] + async fn worker_failure_is_reported_and_releases_slot() { + let slots = Arc::new(Semaphore::new(1)); + let error = WorkPermit::acquire(Arc::clone(&slots)) + .unwrap() + .run(|| panic!("test worker failure")) + .await + .unwrap_err(); + assert!(matches!(error, WorkError::WorkerFailed(_))); + assert_eq!( + error.into_response().status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!(slots.available_permits(), 1); + } +} diff --git a/service/tests/startup.rs b/service/tests/startup.rs new file mode 100644 index 0000000..fcaccdc --- /dev/null +++ b/service/tests/startup.rs @@ -0,0 +1,210 @@ +use std::{ + fs, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::Path, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use simplicity_unchained_core::utils::generate_keypair; +use tempfile::{NamedTempFile, TempDir}; + +struct ServiceProcess(Child); + +impl ServiceProcess { + fn spawn(directory: &Path, config: Option<&Path>) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_service")); + command.arg("start").current_dir(directory); + if let Some(config) = config { + command.arg("--config").arg(config); + } + Self( + command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ) + } + + fn assert_failure(&mut self, expected: &str) -> String { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(status) = self.0.try_wait().unwrap() { + assert!( + !status.success(), + "startup unexpectedly exited successfully" + ); + let mut logs = String::new(); + self.0 + .stdout + .take() + .unwrap() + .read_to_string(&mut logs) + .unwrap(); + self.0 + .stderr + .take() + .unwrap() + .read_to_string(&mut logs) + .unwrap(); + assert!(logs.contains(expected), "missing startup error: {expected}"); + return logs; + } + assert!(Instant::now() < deadline, "startup did not fail promptly"); + thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for ServiceProcess { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn configuration(key: &str, port: u16) -> String { + format!( + "[service]\nport = {port}\nprivate_key = \"{key}\"\n\ + elements_network = \"liquidtestnet\"\nbitcoin_network = \"testnet4\"\n" + ) +} + +fn write_configuration(directory: &Path, contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::new_in(directory).unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + file +} + +#[test] +fn missing_configuration_exits_unsuccessfully() { + let directory = TempDir::new().unwrap(); + ServiceProcess::spawn(directory.path(), None).assert_failure("Failed to load config"); + ServiceProcess::spawn( + directory.path(), + Some(&directory.path().join("missing.toml")), + ) + .assert_failure("Failed to load config"); + ServiceProcess::spawn(directory.path(), Some(directory.path())) + .assert_failure("Failed to load config"); +} + +#[test] +fn missing_empty_and_invalid_keys_fail_closed() { + let directory = TempDir::new().unwrap(); + for (key, error) in [ + ("".to_string(), "Invalid private key"), + ("not-hex".to_string(), "Invalid private key hex"), + (hex::encode([0; 32]), "Invalid private key"), + (hex::encode([1; 31]), "Invalid private key"), + ] { + let file = write_configuration(directory.path(), &configuration(&key, 0)); + ServiceProcess::spawn(directory.path(), Some(file.path())).assert_failure(error); + } + let file = write_configuration( + directory.path(), + "[service]\nport = 0\nelements_network = \"liquidtestnet\"\nbitcoin_network = \"testnet4\"\n", + ); + ServiceProcess::spawn(directory.path(), Some(file.path())) + .assert_failure("failed to parse config"); +} + +#[test] +fn malformed_configuration_does_not_log_private_key_contents() { + let directory = TempDir::new().unwrap(); + let marker = "must-not-appear-in-logs"; + let file = write_configuration( + directory.path(), + &configuration(marker, 0).replace( + &format!("private_key = \"{marker}\""), + &format!("private_key = \"{marker}\" invalid"), + ), + ); + let logs = ServiceProcess::spawn(directory.path(), Some(file.path())) + .assert_failure("failed to parse config"); + assert!(!logs.contains(marker)); +} + +#[test] +fn invalid_networks_exit_unsuccessfully() { + let directory = TempDir::new().unwrap(); + let (secret_key, _) = generate_keypair(); + let config = configuration(&hex::encode(secret_key.secret_bytes()), 0); + for (network, error) in [ + ("liquidtestnet", "Failed to parse Elements network"), + ("testnet4", "Failed to parse Bitcoin network"), + ] { + let file = write_configuration( + directory.path(), + &config.replace(network, "invalid-network"), + ); + ServiceProcess::spawn(directory.path(), Some(file.path())).assert_failure(error); + } +} + +#[test] +fn occupied_port_exits_unsuccessfully() { + let directory = TempDir::new().unwrap(); + let listener = TcpListener::bind("0.0.0.0:0").unwrap(); + let (secret_key, _) = generate_keypair(); + let file = write_configuration( + directory.path(), + &configuration( + &hex::encode(secret_key.secret_bytes()), + listener.local_addr().unwrap().port(), + ), + ); + ServiceProcess::spawn(directory.path(), Some(file.path())).assert_failure("Failed to bind"); +} + +#[test] +fn explicit_read_only_configuration_starts_and_serves_requests() { + let directory = TempDir::new().unwrap(); + let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = reservation.local_addr().unwrap(); + let (secret_key, _) = generate_keypair(); + let file = write_configuration( + directory.path(), + &configuration(&hex::encode(secret_key.secret_bytes()), address.port()), + ); + let mut permissions = fs::metadata(file.path()).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(file.path(), permissions).unwrap(); + drop(reservation); + let mut process = ServiceProcess::spawn(directory.path(), Some(file.path())); + let deadline = Instant::now() + Duration::from_secs(3); + let mut connection = loop { + assert!( + process.0.try_wait().unwrap().is_none(), + "service exited during startup" + ); + if let Ok(connection) = TcpStream::connect_timeout(&address, Duration::from_millis(100)) { + break connection; + } + assert!(Instant::now() < deadline, "service did not become ready"); + thread::sleep(Duration::from_millis(10)); + }; + connection + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + connection.write_all( + b"GET /simplicity-unchained/version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ).unwrap(); + let mut response = String::new(); + connection.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200")); + assert!(response.contains("\"version\":")); +} + +#[test] +fn shipped_example_has_no_usable_key() { + let example: toml::Value = toml::from_str(include_str!("../config.example.toml")).unwrap(); + assert_eq!(example["service"]["private_key"].as_str(), Some("")); + let directory = TempDir::new().unwrap(); + let file = write_configuration(directory.path(), include_str!("../config.example.toml")); + ServiceProcess::spawn(directory.path(), Some(file.path())) + .assert_failure("Invalid private key"); +}