diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6df8f1eb1..273536561 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,6 +23,7 @@ tests/ @alpenlabs/rust @alpenlabs/ci /crates/db/ @alpenlabs/database /crates/eectl/ @alpenlabs/evm /crates/evm*/ @alpenlabs/evm +/crates/key-derivation/ @alpenlabs/crypto @alpenlabs/rust /crates/primitives/ @alpenlabs/crypto @alpenlabs/rust /crates/crypto/ @alpenlabs/crypto @alpenlabs/rust /crates/proof*/ @alpenlabs/prover diff --git a/Cargo.lock b/Cargo.lock index 7115938c7..7357cac55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12753,10 +12753,8 @@ dependencies = [ "async-trait", "bitcoin", "chrono", - "corepc-node", "directories", "jsonrpsee", - "miniscript", "rockbound", "strata-bridge-exec", "strata-bridge-rpc-api", @@ -12764,6 +12762,7 @@ dependencies = [ "strata-bridge-tx-builder", "strata-btcio", "strata-common", + "strata-key-derivation", "strata-primitives", "strata-rocksdb", "strata-rpc-api", @@ -12774,6 +12773,7 @@ dependencies = [ "threadpool", "tokio", "tracing", + "zeroize", ] [[package]] @@ -12969,6 +12969,7 @@ dependencies = [ "strata-db", "strata-eectl", "strata-evmexec", + "strata-key-derivation", "strata-mmr", "strata-primitives", "strata-rocksdb", @@ -12988,6 +12989,7 @@ dependencies = [ "tokio", "toml 0.5.11", "tracing", + "zeroize", ] [[package]] @@ -13057,9 +13059,11 @@ dependencies = [ "rand", "secp256k1", "serde_json", + "strata-key-derivation", "strata-primitives", "strata-sp1-guest-builder", "terrors", + "zeroize", ] [[package]] @@ -13138,6 +13142,17 @@ dependencies = [ "jmt", ] +[[package]] +name = "strata-key-derivation" +version = "0.1.0" +dependencies = [ + "bitcoin", + "secp256k1", + "strata-primitives", + "thiserror", + "zeroize", +] + [[package]] name = "strata-mmr" version = "0.1.0" @@ -13182,6 +13197,7 @@ dependencies = [ "strata-test-utils", "thiserror", "tracing", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 254cff836..c3be9373c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/db", "crates/eectl", "crates/evmexec", + "crates/key-derivation", "crates/primitives", "crates/zkvm/adapters/risc0", "crates/zkvm/adapters/sp1", @@ -84,6 +85,7 @@ strata-crypto = { path = "crates/crypto", default-features = false } strata-db = { path = "crates/db" } strata-eectl = { path = "crates/eectl" } strata-evmexec = { path = "crates/evmexec" } +strata-key-derivation = { path = "crates/key-derivation" } strata-mmr = { path = "crates/util/mmr" } strata-native-zkvm-adapter = { path = "crates/zkvm/adapters/native" } strata-primitives = { path = "crates/primitives" } @@ -238,6 +240,7 @@ tracing = "0.1" tracing-opentelemetry = "0.27" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } uuid = { version = "1.0", features = ["v4", "serde"] } +zeroize = { version = "1.8.1", features = ["derive"] } # This is needed for custom build of SP1 [profile.release.build-override] diff --git a/bin/bridge-client/Cargo.toml b/bin/bridge-client/Cargo.toml index 0766a09d5..09e99d983 100644 --- a/bin/bridge-client/Cargo.toml +++ b/bin/bridge-client/Cargo.toml @@ -19,6 +19,7 @@ strata-bridge-sig-manager.workspace = true strata-bridge-tx-builder.workspace = true strata-btcio.workspace = true strata-common.workspace = true +strata-key-derivation.workspace = true strata-primitives.workspace = true strata-rocksdb.workspace = true strata-rpc-api = { workspace = true, features = ["client"] } @@ -33,12 +34,9 @@ bitcoin.workspace = true chrono.workspace = true directories = "5.0.1" jsonrpsee.workspace = true -miniscript.workspace = true rockbound.workspace = true thiserror.workspace = true threadpool.workspace = true tokio.workspace = true tracing.workspace = true - -[dev-dependencies] -corepc-node = { version = "0.4.0", features = ["28_0"] } +zeroize.workspace = true diff --git a/bin/bridge-client/src/args.rs b/bin/bridge-client/src/args.rs index d1fca3391..6e2a2badb 100644 --- a/bin/bridge-client/src/args.rs +++ b/bin/bridge-client/src/args.rs @@ -23,9 +23,15 @@ pub(crate) struct Cli { #[argh( option, - description = "xpriv to be loaded into the bitcoin wallet using the RPC client (default: envvar STRATA_OP_ROOT_XPRIV)" + description = "xpriv to be used as the master operator's key (default: envvar STRATA_OP_MASTER_XPRIV)" )] - pub root_xpriv: Option, + pub master_xpriv: Option, + + #[argh( + option, + description = "path to the file containing the master operator's xpriv (don't use with --master-xpriv or the envvar STRATA_OP_MASTER_XPRIV)" + )] + pub master_xpriv_path: Option, #[argh( option, diff --git a/bin/bridge-client/src/descriptor.rs b/bin/bridge-client/src/descriptor.rs deleted file mode 100644 index 3d28105d8..000000000 --- a/bin/bridge-client/src/descriptor.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Descriptor parsing utilities. - -use std::env; - -use bitcoin::{ - bip32::{ChildNumber, DerivationPath, Xpriv}, - secp256k1::SECP256K1, -}; -use miniscript::descriptor::{checksum::desc_checksum, InnerXKey}; -use strata_btcio::rpc::{traits::Signer, types::ImportDescriptor}; - -// TODO: move some of these into a keyderiv crate -const DERIV_BASE_IDX: u32 = 56; -const DERIV_OP_IDX: u32 = 20; -const DERIV_OP_SIGNING_IDX: u32 = 100; -const DERIV_OP_WALLET_IDX: u32 = 101; -const OPXPRIV_ENVVAR: &str = "STRATA_OP_ROOT_XPRIV"; -#[allow(unused)] // TODO: uncomment when we need to store the xpriv directly in the wallet. -const WALLET_NAME: &str = "strata"; - -/// Resolves the root key from ENV vars or CLI. -pub(crate) fn resolve_xpriv(cli_arg: Option) -> anyhow::Result { - match cli_arg { - // FIXME make this not a .expect - Some(xpriv_str) => Ok(xpriv_str.parse::().expect("could not parse xpriv")), - - None => match env::var(OPXPRIV_ENVVAR) { - // FIXME make this not a .expect - Ok(xpriv_env_str) => Ok(xpriv_env_str - .parse::() - .expect("could not parse xpriv")), - Err(_) => { - anyhow::bail!("must either set {OPXPRIV_ENVVAR} envvar or pass with `--root-xpriv`") - } - }, - } -} - -/// Derives the signing and wallet xprivs for a Strata operator. -pub(crate) fn derive_op_purpose_xprivs(root: &Xpriv) -> anyhow::Result<(Xpriv, Xpriv)> { - let signing_path = DerivationPath::master().extend([ - ChildNumber::from_hardened_idx(DERIV_BASE_IDX).unwrap(), - ChildNumber::from_hardened_idx(DERIV_OP_IDX).unwrap(), - ChildNumber::from_normal_idx(DERIV_OP_SIGNING_IDX).unwrap(), - ]); - - let wallet_path = DerivationPath::master().extend([ - ChildNumber::from_hardened_idx(DERIV_BASE_IDX).unwrap(), - ChildNumber::from_hardened_idx(DERIV_OP_IDX).unwrap(), - ChildNumber::from_normal_idx(DERIV_OP_WALLET_IDX).unwrap(), - ]); - - let signing_xpriv = root.derive_priv(bitcoin::secp256k1::SECP256K1, &signing_path)?; - let wallet_xpriv = root.derive_priv(bitcoin::secp256k1::SECP256K1, &wallet_path)?; - - Ok((signing_xpriv, wallet_xpriv)) -} - -/// Parses an [`Xpriv`] into a **Taproot** descriptor ready to be imported by Bitcoin core. -/// -/// # Note -/// -/// The current descriptor handling is not easy to do as a type, -/// hence this does all internals checks and then returns the descriptor -/// as a [`String`]. -#[allow(unused)] // TODO: uncomment when we need to store the xpriv directly in the wallet. -pub(crate) fn generate_descriptor_from_xpriv(xpriv: Xpriv) -> anyhow::Result { - let hardened_path = DerivationPath::master().extend([ - ChildNumber::from_hardened_idx(DERIV_BASE_IDX).expect("bad child index"), - ChildNumber::from_hardened_idx(DERIV_OP_IDX).expect("bad child index"), - ]); - - let normal_path = DerivationPath::master() - .extend([ChildNumber::from_normal_idx(DERIV_OP_WALLET_IDX).expect("bad child index")]); - - let fingerprint = xpriv.xkey_fingerprint(SECP256K1); - let descriptor_str = format!("tr([{fingerprint}/{hardened_path}]{xpriv}/{normal_path}/*)"); - let checksum = desc_checksum(&descriptor_str).expect("could not calculate descriptor checksum"); - // tr([fingerprint/hardened_path]/normal_path/*)#checksum - let descriptor_str = format!("{descriptor_str}#{checksum}"); - - Ok(descriptor_str) -} - -/// Checks if the wallet has the descriptor or loads it into the wallet. -#[allow(unused)] // TODO: uncomment when we need to store the xpriv directly in the wallet. -pub(crate) async fn check_or_load_descriptor_into_wallet( - l1_client: &impl Signer, - xpriv: Xpriv, -) -> anyhow::Result<()> { - let xpriv_from_signer = l1_client - .get_xpriv() - .await - .expect("could not get the listdescriptors call from the bitcoin RPC") - .expect("could not get a valid xpriv from the bitcoin wallet"); - if xpriv == xpriv_from_signer { - // nothing to do - Ok(()) - } else { - // load the descriptor - let descriptor = generate_descriptor_from_xpriv(xpriv)?; - let descriptors = vec![ImportDescriptor { - desc: descriptor, - active: Some(true), - timestamp: "now".to_owned(), - }]; - let result = l1_client - .import_descriptors(descriptors, WALLET_NAME.to_string()) - .await - .expect("could not get the importdescriptors call from the bitcoin RPC"); - assert!( - result.iter().all(|r| r.success), - "could not import xpriv as a descriptor into the wallet" - ); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use corepc_node::BitcoinD; - use strata_btcio::rpc::{ - traits::Signer, - types::{ImportDescriptor, ImportDescriptorResult}, - BitcoinClient, - }; - use strata_common::logging; - - use super::*; - - // taken from https://github.com/rust-bitcoin/rust-bitcoin/blob/bb38aeb786f408247d5bbc88b9fa13616c74c009/bitcoin/examples/taproot-psbt.rs#L18C38-L18C149 - const XPRIV_STR: &str = "tprv8ZgxMBicQKsPd4arFr7sKjSnKFDVMR2JHw9Y8L9nXN4kiok4u28LpHijEudH3mMYoL4pM5UL9Bgdz2M4Cy8EzfErmU9m86ZTw6hCzvFeTg7"; - - /// Get the authentication credentials for a given [`bitcoind`] instance. - fn get_auth(bitcoind: &BitcoinD) -> (String, String) { - let params = &bitcoind.params; - let cookie_values = params.get_cookie_values().unwrap().unwrap(); - (cookie_values.user, cookie_values.password) - } - - #[test] - fn parse_xpriv_to_descriptor_string() { - let expected = "tr([e61b318f/56'/20']tprv8ZgxMBicQKsPd4arFr7sKjSnKFDVMR2JHw9Y8L9nXN4kiok4u28LpHijEudH3mMYoL4pM5UL9Bgdz2M4Cy8EzfErmU9m86ZTw6hCzvFeTg7/101/*)#zz430whl"; - let xpriv = XPRIV_STR.parse::().unwrap(); - let got = generate_descriptor_from_xpriv(xpriv).unwrap(); - assert_eq!(got, expected); - } - - #[tokio::test] - async fn bitcoind_import_descriptors() { - logging::init(logging::LoggerConfig::with_base_name( - "bridge-client-descriptor-tests", - )); - let bitcoind = BitcoinD::from_downloaded().unwrap(); - let url = bitcoind.rpc_url(); - let (user, password) = get_auth(&bitcoind); - let client = BitcoinClient::new(url, user, password).unwrap(); - - let xpriv = XPRIV_STR.parse::().unwrap(); - let descriptor_string = generate_descriptor_from_xpriv(xpriv).unwrap(); - let timestamp = "now".to_owned(); - let list_descriptors = vec![ImportDescriptor { - desc: descriptor_string, - active: Some(true), - timestamp, - }]; - let got = client - .import_descriptors(list_descriptors, WALLET_NAME.to_string()) - .await - .unwrap(); - let expected = vec![ImportDescriptorResult { success: true }]; - assert_eq!(expected, got); - } -} diff --git a/bin/bridge-client/src/main.rs b/bin/bridge-client/src/main.rs index b120624b1..5d0d81a62 100644 --- a/bin/bridge-client/src/main.rs +++ b/bin/bridge-client/src/main.rs @@ -6,10 +6,10 @@ mod args; pub(crate) mod constants; pub(crate) mod db; -mod descriptor; mod errors; mod modes; pub(crate) mod rpc_server; +pub(crate) mod xpriv; use args::{Cli, OperationMode}; use modes::{challenger, operator}; diff --git a/bin/bridge-client/src/modes/operator/bootstrap.rs b/bin/bridge-client/src/modes/operator/bootstrap.rs index 363f3034f..a808bd0d6 100644 --- a/bin/bridge-client/src/modes/operator/bootstrap.rs +++ b/bin/bridge-client/src/modes/operator/bootstrap.rs @@ -29,15 +29,15 @@ use crate::{ args::Cli, constants::{DEFAULT_RPC_HOST, DEFAULT_RPC_PORT, ROCKSDB_RETRY_COUNT}, db::open_rocksdb_database, - descriptor::{derive_op_purpose_xprivs, resolve_xpriv}, rpc_server::{self, BridgeRpc}, + xpriv::resolve_xpriv, }; /// Bootstraps the bridge client in Operator mode by hooking up all the required auxiliary services /// including database, rpc server, etc. Logging needs to be initialized at the call /// site (main function) itself. pub(crate) async fn bootstrap(args: Cli) -> anyhow::Result<()> { - // Parse the data_dir + // Parse dirs let data_dir = args.datadir.map(PathBuf::from); // Initialize a rocksdb instance with the required column families. @@ -68,8 +68,8 @@ pub(crate) async fn bootstrap(args: Cli) -> anyhow::Result<()> { .expect("failed to connect to the rollup RPC server"); // Get the keypair after deriving the wallet xpriv. - let root_xpriv = resolve_xpriv(args.root_xpriv)?; - let (_, wallet_xpriv) = derive_op_purpose_xprivs(&root_xpriv)?; + let operator_keys = resolve_xpriv(args.master_xpriv, args.master_xpriv_path)?; + let wallet_xpriv = operator_keys.wallet_xpriv(); let mut keypair = wallet_xpriv.to_keypair(SECP256K1); let mut sk = SecretKey::from_keypair(&keypair); diff --git a/bin/bridge-client/src/xpriv.rs b/bin/bridge-client/src/xpriv.rs new file mode 100644 index 000000000..1847f8739 --- /dev/null +++ b/bin/bridge-client/src/xpriv.rs @@ -0,0 +1,73 @@ +//! Parses the operator's master xpriv from a file. + +use std::{ + env, + fs::read_to_string, + path::{Path, PathBuf}, +}; + +use bitcoin::bip32::Xpriv; +use strata_key_derivation::operator::OperatorKeys; +use strata_primitives::keys::ZeroizableXpriv; +use zeroize::Zeroize; + +/// The environment variable that contains the operator's master [`Xpriv`]. +const OPXPRIV_ENVVAR: &str = "STRATA_OP_MASTER_XPRIV"; + +/// Parses the master [`Xpriv`] from a file. +pub(crate) fn parse_master_xpriv(path: &Path) -> anyhow::Result { + let mut xpriv_str = read_to_string(path)?; + match xpriv_str.parse::() { + Ok(mut xpriv) => { + // Zeroize the xpriv string after parsing it. + xpriv_str.zeroize(); + + // Parse into ZeroizableXpriv + let zeroizable_xpriv: ZeroizableXpriv = xpriv.into(); + + // Zeroize the xpriv after parsing it. + xpriv.private_key.non_secure_erase(); + + // Finally return the operator keys + // + // NOTE: `zeroizable_xpriv` is zeroized on drop. + Ok(OperatorKeys::new(&zeroizable_xpriv) + .map_err(|_| anyhow::anyhow!("invalid master xpriv"))?) + } + Err(e) => anyhow::bail!("invalid master xpriv: {}", e), + } +} + +/// Resolves the master [`Xpriv`] from CLI arguments or environment variables. +/// +/// Precedence order for resolving the master xpriv: +/// +/// 1. If a key is supplied via the `--master-xpriv` CLI argument, it is used. +/// 2. Otherwise, if a file path is supplied via CLI, the key is read from that file. +/// 3. Otherwise, if the `STRATA_OP_MASTER_XPRIV` environment variable is set, its value is used. +/// 4. Otherwise, returns an error. +/// +/// # Errors +/// +/// Returns an error if the master xpriv is invalid or not found. +pub(crate) fn resolve_xpriv( + cli_arg: Option, + cli_path: Option, +) -> anyhow::Result { + match (cli_arg, cli_path) { + (Some(xpriv), _) => OperatorKeys::new(&xpriv.parse::()?) + .map_err(|_| anyhow::anyhow!("invalid master xpriv from CLI")), + + (_, Some(path)) => parse_master_xpriv(&PathBuf::from(path)), + + (None, None) => match env::var(OPXPRIV_ENVVAR) { + Ok(xpriv_env_str) => OperatorKeys::new(&xpriv_env_str.parse::()?) + .map_err(|_| anyhow::anyhow!("invalid master xpriv from envvar")), + Err(_) => { + anyhow::bail!( + "must either set {OPXPRIV_ENVVAR} envvar or pass with `--master-xpriv`" + ) + } + }, + } +} diff --git a/bin/datatool/Cargo.toml b/bin/datatool/Cargo.toml index 5e43d65e0..63be3fb3d 100644 --- a/bin/datatool/Cargo.toml +++ b/bin/datatool/Cargo.toml @@ -8,6 +8,7 @@ name = "strata-datatool" path = "src/main.rs" [dependencies] +strata-key-derivation.workspace = true strata-primitives.workspace = true strata-sp1-guest-builder = { path = "../../provers/sp1" } @@ -20,3 +21,7 @@ rand.workspace = true secp256k1 = { workspace = true, features = ["global-context", "std"] } serde_json.workspace = true terrors = "0.3.0" +zeroize = { workspace = true, optional = true } + +[features] +default = ["zeroize"] diff --git a/bin/datatool/README.md b/bin/datatool/README.md index 79f17f577..a5a41c7ee 100644 --- a/bin/datatool/README.md +++ b/bin/datatool/README.md @@ -8,9 +8,9 @@ The basic flow to generate a params file with it looks like this: ``` # Generate keys for the different parties each on different machines. -strata-datatool genseed sequencer.bin -strata-datatool genseed operator1.bin -strata-datatool genseed operator2.bin +strata-datatool genxpriv sequencer.bin +strata-datatool genxpriv operator1.bin +strata-datatool genxpriv operator2.bin # Generate the pubkeys, also on their original machines. strata-datatool genseqpubkey -f sequencer.bin @@ -33,4 +33,4 @@ Alternatively, instead of passing `-f`, you can pass `-E` and define either ## Correct Setup for VerifyingKey To ensure the RollupParams contain the correct verifying key, you must run the binary in release mode. This is necessary because the SP1 build process requires release mode for proper functioning. -Before proceeding, make sure that you have SP1 correctly set up by following the installation instructions provided [here](https://docs.succinct.xyz/getting-started/install.html). Additionally, refer to the [common errors section](https://docs.succinct.xyz/developers/common-issues.html#c-binding-errors) here to troubleshoot any issues, especially C binding errors. \ No newline at end of file +Before proceeding, make sure that you have SP1 correctly set up by following the installation instructions provided [here](https://docs.succinct.xyz/getting-started/install.html). Additionally, refer to the [common errors section](https://docs.succinct.xyz/developers/common-issues.html#c-binding-errors) here to troubleshoot any issues, especially C binding errors. diff --git a/bin/datatool/src/args.rs b/bin/datatool/src/args.rs new file mode 100644 index 000000000..a9a1ae386 --- /dev/null +++ b/bin/datatool/src/args.rs @@ -0,0 +1,187 @@ +//! Command line arguments for Strata's `datatool` binary. + +use std::path::PathBuf; + +use argh::FromArgs; +use bitcoin::Network; +use rand::rngs::OsRng; + +/// Args. +#[derive(FromArgs)] +pub(crate) struct Args { + #[argh(option, description = "network name [signet, regtest]", short = 'b')] + pub(crate) bitcoin_network: Option, + + #[argh( + option, + description = "data directory (unused) (default cwd)", + short = 'd' + )] + pub(crate) datadir: Option, + + #[argh(subcommand)] + pub(crate) subc: Subcommand, +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +pub(crate) enum Subcommand { + Xpriv(SubcXpriv), + SeqPubkey(SubcSeqPubkey), + SeqPrivkey(SubcSeqPrivkey), + OpXpub(SubcOpXpub), + Params(SubcParams), +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, + name = "genxpriv", + description = "generates a master xpriv and writes it to a file" +)] +pub(crate) struct SubcXpriv { + #[argh(positional, description = "output path")] + pub(crate) path: PathBuf, + + #[argh(switch, description = "force overwrite", short = 'f')] + pub(crate) force: bool, +} + +/// Generate the sequencer pubkey to pass around. +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, + name = "genseqpubkey", + description = "generates a sequencer pubkey from a master xpriv" +)] +pub(crate) struct SubcSeqPubkey { + #[argh(option, description = "reads key from specified file", short = 'f')] + pub(crate) key_file: Option, + + #[argh( + switch, + description = "reads key from envvar STRATA_SEQ_KEY", + short = 'E' + )] + pub(crate) key_from_env: bool, +} + +/// Generate the sequencer pubkey to pass around. +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, + name = "genseqprivkey", + description = "generates a sequencer privkey from a master xpriv" +)] +pub(crate) struct SubcSeqPrivkey { + #[argh(option, description = "reads key from specified file", short = 'f')] + pub(crate) key_file: Option, + + #[argh( + switch, + description = "reads key from envvar STRATA_SEQ_KEY", + short = 'E' + )] + pub(crate) key_from_env: bool, +} + +/// Generate operator xpub to pass around. +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, + name = "genopxpub", + description = "generates an operator xpub from a master xpriv" +)] +pub(crate) struct SubcOpXpub { + #[argh(option, description = "reads key from specified file", short = 'f')] + pub(crate) key_file: Option, + + #[argh( + switch, + description = "reads key from envvar STRATA_OP_KEY", + short = 'E' + )] + pub(crate) key_from_env: bool, +} + +/// Generate a network's param file from inputs. +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, + name = "genparams", + description = "generates network params from inputs" +)] +pub(crate) struct SubcParams { + #[argh( + option, + description = "output file path .json (default stdout)", + short = 'o' + )] + pub(crate) output: Option, + + #[argh( + option, + description = "network name, used for magics (default random)", + short = 'n' + )] + pub(crate) name: Option, + + #[argh( + option, + description = "sequencer pubkey (default unchecked)", + short = 's' + )] + pub(crate) seqkey: Option, + + #[argh( + option, + description = "add a bridge operator key (must be at least one, appended after file keys)", + short = 'b' + )] + pub(crate) opkey: Vec, + + #[argh( + option, + description = "read bridge operator keys by line from file", + short = 'B' + )] + pub(crate) opkeys: Option, + + #[argh(option, description = "deposit amount in sats (default \"10 BTC\")")] + pub(crate) deposit_sats: Option, + + #[argh( + option, + description = "genesis trigger height (default 100)", + short = 'g' + )] + pub(crate) genesis_trigger_height: Option, + + #[argh( + option, + description = "block time in seconds (default 15)", + short = 't' + )] + pub(crate) block_time: Option, + + #[argh(option, description = "epoch duration in slots (default 64)")] + pub(crate) epoch_slots: Option, + + #[argh( + option, + description = "permit blank proofs after timeout in millis (default strict)" + )] + pub(crate) proof_timeout: Option, +} + +pub(crate) struct CmdContext { + /// Resolved datadir for the network. + #[allow(unused)] + pub(crate) datadir: PathBuf, + + /// The Bitcoin network we're building on top of. + pub(crate) bitcoin_network: Network, + + /// Shared RNG, must be a cryptographically secure, high-entropy RNG. + pub(crate) rng: OsRng, +} diff --git a/bin/datatool/src/main.rs b/bin/datatool/src/main.rs index f61b427f8..570549129 100644 --- a/bin/datatool/src/main.rs +++ b/bin/datatool/src/main.rs @@ -1,231 +1,26 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; +//! Command line tool for generating test data for Strata. +//! +//! # Warning +//! +//! This tool is intended for use in testing and development only. It generates +//! keys and other data that should not be used in production. +mod args; +mod util; -use argh::FromArgs; -use bitcoin::{ - base58, - bip32::{ChildNumber, DerivationPath, Xpriv, Xpub}, - secp256k1::SECP256K1, - Network, -}; -use rand::{rngs::OsRng, CryptoRng, RngCore}; -use strata_primitives::{ - block_credential, - buf::Buf32, - operator::OperatorPubkeys, - params::{ProofPublishMode, RollupParams}, - proof::RollupVerifyingKey, -}; -use strata_sp1_guest_builder::GUEST_CHECKPOINT_VK_HASH_STR; +use std::path::PathBuf; -// TODO move some of these into a keyderiv crate -const DERIV_BASE_IDX: u32 = 56; -const DERIV_SEQ_IDX: u32 = 10; -const DERIV_OP_IDX: u32 = 20; -const DERIV_OP_SIGNING_IDX: u32 = 100; -const DERIV_OP_WALLET_IDX: u32 = 101; -const SEQKEY_ENVVAR: &str = "STRATA_SEQ_KEY"; -const OPKEY_ENVVAR: &str = "STRATA_OP_KEY"; -const DEFAULT_NETWORK: Network = Network::Signet; - -/// List of keys that are used in examples that we don't want people to actually -/// use. If any of these are present in a command invocation we abort immediately. -const KEY_BLACKLIST: &[&str] = &[ - "XGUgTAJNpexzrjgnbMvGtDBCZEwxd6KQE4PNDWE6YLZYBTGoS", - "tpubDASVk1m5cxpmUbwVEZEQb8maDVx9kDxBhSLCqsKHJJmZ8htSegpHx7G3RFudZCdDLtNKTosQiBLbbFsVA45MemurWenzn16Y1ft7NkQekcD", - "tpubDBX9KQsqK2LMCszkDHvANftHzhJdhipe9bi9MNUD3S2bsY1ikWEZxE53VBgYN8WoNXk9g9eRzhx6UfJcQr3XqkA27aSxXvKu5TYFZJEAjCd" -]; - -/// Args. -#[derive(FromArgs)] -pub struct Args { - #[argh(option, description = "network name [signet, regtest]", short = 'b')] - bitcoin_network: Option, - - #[argh( - option, - description = "data directory (unused) (default cwd)", - short = 'd' - )] - datadir: Option, - - #[argh(subcommand)] - subc: Subcommand, -} - -#[derive(FromArgs, PartialEq, Debug)] -#[argh(subcommand)] -pub enum Subcommand { - GenSeed(SubcGenSeed), - GenSeqPubkey(SubcGenSeqPubkey), - GenSeqPrivkey(SubcGenSeqPrivkey), - GenOpXpub(SubcGenOpXpub), - GenParams(SubcGenParams), -} - -#[derive(FromArgs, PartialEq, Debug)] -#[argh( - subcommand, - name = "genseed", - description = "generates a xpriv and writes it to a file" -)] -pub struct SubcGenSeed { - #[argh(positional, description = "output path")] - path: PathBuf, - - #[argh(switch, description = "force overwrite", short = 'f')] - force: bool, -} - -/// Generate the sequencer pubkey to pass around. -#[derive(FromArgs, PartialEq, Debug)] -#[argh( - subcommand, - name = "genseqpubkey", - description = "generates a sequencer pubkey from seed" -)] -pub struct SubcGenSeqPubkey { - #[argh(option, description = "reads key from specified file", short = 'f')] - key_file: Option, - - #[argh( - switch, - description = "reads key from envvar STRATA_SEQ_KEY", - short = 'E' - )] - key_from_env: bool, -} - -/// Generate the sequencer pubkey to pass around. -#[derive(FromArgs, PartialEq, Debug)] -#[argh( - subcommand, - name = "genseqprivkey", - description = "generates a sequencer privkey from seed" -)] -pub struct SubcGenSeqPrivkey { - #[argh(option, description = "reads key from specified file", short = 'f')] - key_file: Option, - - #[argh( - switch, - description = "reads key from envvar STRATA_SEQ_KEY", - short = 'E' - )] - key_from_env: bool, -} - -/// Generate operator xpub to pass around. -#[derive(FromArgs, PartialEq, Debug)] -#[argh( - subcommand, - name = "genopxpub", - description = "generates an operator xpub from seed" -)] -pub struct SubcGenOpXpub { - #[argh(option, description = "reads key from specified file", short = 'f')] - key_file: Option, - - #[argh( - switch, - description = "reads key from envvar STRATA_OP_KEY", - short = 'E' - )] - key_from_env: bool, -} - -/// Generate a network's param file from inputs. -#[derive(FromArgs, PartialEq, Debug)] -#[argh( - subcommand, - name = "genparams", - description = "generates network params from inputs" -)] -pub struct SubcGenParams { - #[argh( - option, - description = "output file path .json (default stdout)", - short = 'o' - )] - output: Option, - - #[argh( - option, - description = "network name, used for magics (default random)", - short = 'n' - )] - name: Option, - - #[argh( - option, - description = "sequencer pubkey (default unchecked)", - short = 's' - )] - seqkey: Option, - - #[argh( - option, - description = "add a bridge operator key (must be at least one, appended after file keys)", - short = 'b' - )] - opkey: Vec, - - #[argh( - option, - description = "read bridge operator keys by line from file", - short = 'B' - )] - opkeys: Option, - - #[argh(option, description = "deposit amount in sats (default \"10 BTC\")")] - deposit_sats: Option, - - #[argh( - option, - description = "genesis trigger height (default 100)", - short = 'g' - )] - genesis_trigger_height: Option, - - #[argh( - option, - description = "block time in seconds (default 15)", - short = 't' - )] - block_time: Option, - - #[argh(option, description = "epoch duration in slots (default 64)")] - epoch_slots: Option, - - #[argh( - option, - description = "permit blank proofs after timeout in millis (default strict)" - )] - proof_timeout: Option, -} - -pub struct CmdContext { - /// Resolved datadir for the network. - #[allow(unused)] - datadir: PathBuf, - - /// The Bitcoin network we're building on top of. - bitcoin_network: Network, - - /// Shared RNG, just [`OsRng`] for now. - rng: OsRng, -} +use args::CmdContext; +use rand::rngs::OsRng; +use util::{exec_subc, resolve_network}; fn main() { - let args: Args = argh::from_env(); + let args: args::Args = argh::from_env(); if let Err(e) = main_inner(args) { eprintln!("ERROR\n{e:?}"); } } -fn main_inner(args: Args) -> anyhow::Result<()> { +fn main_inner(args: args::Args) -> anyhow::Result<()> { let network = resolve_network(args.bitcoin_network.as_deref())?; let mut ctx = CmdContext { @@ -237,347 +32,3 @@ fn main_inner(args: Args) -> anyhow::Result<()> { exec_subc(args.subc, &mut ctx)?; Ok(()) } - -fn resolve_network(arg: Option<&str>) -> anyhow::Result { - match arg { - Some("signet") => Ok(Network::Signet), - Some("regtest") => Ok(Network::Regtest), - Some(n) => anyhow::bail!("unsupported network option: {n}"), - None => Ok(DEFAULT_NETWORK), - } -} - -fn exec_subc(cmd: Subcommand, ctx: &mut CmdContext) -> anyhow::Result<()> { - match cmd { - Subcommand::GenSeed(subc) => exec_genseed(subc, ctx), - Subcommand::GenSeqPubkey(subc) => exec_genseqpubkey(subc, ctx), - Subcommand::GenSeqPrivkey(subc) => exec_genseqprivkey(subc, ctx), - Subcommand::GenOpXpub(subc) => exec_genopxpub(subc, ctx), - Subcommand::GenParams(subc) => exec_genparams(subc, ctx), - } -} - -fn exec_genseed(cmd: SubcGenSeed, ctx: &mut CmdContext) -> anyhow::Result<()> { - if cmd.path.exists() && !cmd.force { - anyhow::bail!("not overwriting file, add --force to overwrite"); - } - - let xpriv = gen_priv(&mut ctx.rng, ctx.bitcoin_network); - let buf = xpriv.encode(); - let s = base58::encode_check(&buf); - fs::write(&cmd.path, s.as_bytes())?; - - Ok(()) -} - -fn exec_genseqpubkey(cmd: SubcGenSeqPubkey, _ctx: &mut CmdContext) -> anyhow::Result<()> { - let Some(xpriv) = resolve_xpriv(&cmd.key_file, cmd.key_from_env, SEQKEY_ENVVAR)? else { - anyhow::bail!("privkey unset"); - }; - - let seq_xpriv = derive_seq_xpriv(&xpriv)?; - let seq_xpub = Xpub::from_priv(SECP256K1, &seq_xpriv); - let raw_buf = seq_xpub.to_x_only_pub().serialize(); - let s = base58::encode_check(&raw_buf); - - println!("{s}"); - - Ok(()) -} - -fn exec_genseqprivkey(cmd: SubcGenSeqPrivkey, _ctx: &mut CmdContext) -> anyhow::Result<()> { - let Some(xpriv) = resolve_xpriv(&cmd.key_file, cmd.key_from_env, SEQKEY_ENVVAR)? else { - anyhow::bail!("privkey unset"); - }; - - let seq_xpriv = derive_seq_xpriv(&xpriv)?; - let raw_buf = seq_xpriv.to_priv().to_bytes(); - let s = base58::encode_check(&raw_buf); - - println!("{s}"); - - Ok(()) -} - -fn exec_genopxpub(cmd: SubcGenOpXpub, _ctx: &mut CmdContext) -> anyhow::Result<()> { - let Some(xpriv) = resolve_xpriv(&cmd.key_file, cmd.key_from_env, OPKEY_ENVVAR)? else { - anyhow::bail!("privkey unset"); - }; - - let op_xpriv = derive_op_root_xpub(&xpriv)?; - let op_xpub = Xpub::from_priv(SECP256K1, &op_xpriv); - let raw_buf = op_xpub.encode(); - let s = base58::encode_check(&raw_buf); - - println!("{s}"); - - Ok(()) -} - -fn exec_genparams(cmd: SubcGenParams, ctx: &mut CmdContext) -> anyhow::Result<()> { - // Parse the sequencer key, trimming whitespace for convenience. - let seqkey = match cmd.seqkey.as_ref().map(|s| s.trim()) { - Some(seqkey) => { - let buf = match base58::decode_check(seqkey) { - Ok(v) => v, - Err(e) => { - anyhow::bail!("failed to parse sequencer key '{seqkey}': {e}"); - } - }; - - let Ok(buf) = Buf32::try_from(buf.as_slice()) else { - anyhow::bail!("invalid sequencer key '{seqkey}' (must be 32 bytes)"); - }; - - Some(buf) - } - None => None, - }; - - // Parse each of the operator keys. - let mut opkeys = Vec::new(); - - if let Some(opkeys_path) = cmd.opkeys { - let opkeys_str = fs::read_to_string(opkeys_path)?; - - for l in opkeys_str.lines() { - // skip lines that are empty or look like comments - if l.trim().is_empty() || l.starts_with("#") { - continue; - } - - opkeys.push(parse_xpub(l)?); - } - } - - for k in cmd.opkey { - opkeys.push(parse_xpub(&k)?); - } - - // Parse the deposit size str. - let deposit_sats = cmd - .deposit_sats - .map(|s| parse_abbr_amt(&s)) - .transpose()? - .unwrap_or(1_000_000_000); - - // Parse the checkpoint verification key. - let rollup_vk: Buf32 = GUEST_CHECKPOINT_VK_HASH_STR.parse().unwrap(); - - let config = ParamsConfig { - name: cmd.name.unwrap_or_else(|| "strata-testnet".to_string()), - bitcoin_network: ctx.bitcoin_network, - // TODO make these consts - block_time_sec: cmd.block_time.unwrap_or(15), - epoch_slots: cmd.epoch_slots.unwrap_or(64), - genesis_trigger: cmd.genesis_trigger_height.unwrap_or(100), - seqkey, - opkeys, - rollup_vk, - // TODO make a const - deposit_sats, - proof_timeout: cmd.proof_timeout, - }; - - let params = construct_params(config); - let params_buf = serde_json::to_string_pretty(¶ms)?; - - if let Some(out_path) = &cmd.output { - fs::write(out_path, params_buf)?; - eprintln!("wrote to file {out_path:?}"); - } else { - println!("{params_buf}"); - } - - Ok(()) -} - -/// Generates a new xpriv. -fn gen_priv(rng: &mut R, net: Network) -> Xpriv { - let mut seed = [0u8; 32]; - rng.fill_bytes(&mut seed); - Xpriv::new_master(net, &seed).expect("valid seed") -} - -/// Reads an xprv from file as a string, verifying the checksom. -fn read_xpriv(path: &Path) -> anyhow::Result { - let raw_buf = fs::read(path)?; - let str_buf = std::str::from_utf8(&raw_buf)?; - let buf = base58::decode_check(str_buf)?; - Ok(Xpriv::decode(&buf)?) -} - -/// Resolves a key from set vars and whatnot. -fn resolve_xpriv( - path: &Option, - from_env: bool, - env: &'static str, -) -> anyhow::Result> { - match (path, from_env) { - (Some(_), true) => anyhow::bail!("got key path and --key-from-env, pick a lane"), - (Some(path), false) => Ok(Some(read_xpriv(path)?)), - (None, true) => { - let Ok(val) = std::env::var(env) else { - anyhow::bail!("got --key-from-env but {env} not set or invalid"); - }; - - let buf = base58::decode_check(&val)?; - Ok(Some(Xpriv::decode(&buf)?)) - } - _ => Ok(None), - } -} - -fn derive_strata_scheme_xpriv(master: &Xpriv, last: u32) -> anyhow::Result { - let derivation_path = DerivationPath::master().extend([ - ChildNumber::from_hardened_idx(DERIV_BASE_IDX).unwrap(), - ChildNumber::from_hardened_idx(last).unwrap(), - ]); - Ok(master.derive_priv(SECP256K1, &derivation_path)?) -} - -/// Derives the sequencer xpriv. -fn derive_seq_xpriv(master: &Xpriv) -> anyhow::Result { - derive_strata_scheme_xpriv(master, DERIV_SEQ_IDX) -} - -/// Derives the root xpub for a Strata operator which can be turned into an xpub -/// and used in network init. -fn derive_op_root_xpub(master: &Xpriv) -> anyhow::Result { - derive_strata_scheme_xpriv(master, DERIV_OP_IDX) -} - -/// Derives the signing and wallet xprivs for a Strata operator. -fn derive_op_purpose_xpubs(op_xpub: &Xpub) -> (Xpub, Xpub) { - let signing_path = DerivationPath::master() - .extend([ChildNumber::from_normal_idx(DERIV_OP_SIGNING_IDX).unwrap()]); - - let wallet_path = DerivationPath::master() - .extend([ChildNumber::from_normal_idx(DERIV_OP_WALLET_IDX).unwrap()]); - - let signing_xpub = op_xpub.derive_pub(SECP256K1, &signing_path).unwrap(); - let wallet_xpub = op_xpub.derive_pub(SECP256K1, &wallet_path).unwrap(); - - (signing_xpub, wallet_xpub) -} - -/// Describes inputs for how we want to set params. -pub struct ParamsConfig { - name: String, - #[allow(unused)] - bitcoin_network: Network, - block_time_sec: u64, - epoch_slots: u32, - genesis_trigger: u64, - seqkey: Option, - opkeys: Vec, - rollup_vk: Buf32, - deposit_sats: u64, - proof_timeout: Option, -} - -// TODO convert this to also initialize the sync params -fn construct_params(config: ParamsConfig) -> RollupParams { - let cr = config - .seqkey - .map(block_credential::CredRule::SchnorrKey) - .unwrap_or(block_credential::CredRule::Unchecked); - - let opkeys = config - .opkeys - .into_iter() - .map(|xpk| { - let (signing_key, wallet_key) = derive_op_purpose_xpubs(&xpk); - let signing_key_buf = signing_key.to_x_only_pub().serialize().into(); - let wallet_key_buf = wallet_key.to_x_only_pub().serialize().into(); - OperatorPubkeys::new(signing_key_buf, wallet_key_buf) - }) - .collect::>(); - - // TODO add in bitcoin network - - RollupParams { - rollup_name: config.name, - block_time: config.block_time_sec * 1000, - cred_rule: cr, - // TODO do we want to remove this? - horizon_l1_height: config.genesis_trigger / 2, - genesis_l1_height: config.genesis_trigger, - operator_config: strata_primitives::params::OperatorConfig::Static(opkeys), - // TODO make configurable - evm_genesis_block_hash: - "0x37ad61cff1367467a98cf7c54c4ac99e989f1fbb1bc1e646235e90c065c565ba" - .parse() - .unwrap(), - evm_genesis_block_state_root: - "0x351714af72d74259f45cd7eab0b04527cd40e74836a45abcae50f92d919d988f" - .parse() - .unwrap(), - // TODO make configurable - l1_reorg_safe_depth: 4, - target_l2_batch_size: config.epoch_slots as u64, - address_length: 20, - deposit_amount: config.deposit_sats, - rollup_vk: RollupVerifyingKey::SP1VerifyingKey(config.rollup_vk), - // TODO make configurable - dispatch_assignment_dur: 64, - proof_publish_mode: config - .proof_timeout - .map(|t| ProofPublishMode::Timeout(t as u64)) - .unwrap_or(ProofPublishMode::Strict), - // TODO make configurable - max_deposits_in_block: 16, - network: config.bitcoin_network, - } -} - -/// Returns an `Err` if the provided key we're trying to parse is on the blacklist. -fn check_key_not_blacklisted(s: &str) -> anyhow::Result<()> { - let ts = s.trim(); - if KEY_BLACKLIST.contains(&ts) { - anyhow::bail!("that was an example! generate your own keys!"); - } - - Ok(()) -} - -/// Parses an [`Xpub`] from [`&str`], richly generating [`anyhow::Result`]s from -/// it. -fn parse_xpub(s: &str) -> anyhow::Result { - check_key_not_blacklisted(s)?; - - let Ok(buf) = base58::decode_check(s) else { - anyhow::bail!("failed to parse key: {s}"); - }; - - let Ok(xpk) = Xpub::decode(&buf) else { - anyhow::bail!("failed to decode key: {s}"); - }; - - Ok(xpk) -} - -fn parse_abbr_amt(s: &str) -> anyhow::Result { - // Thousand. - if let Some(v) = s.strip_suffix("K") { - return Ok(v.parse::()? * 1000); - } - - // Million. - if let Some(v) = s.strip_suffix("M") { - return Ok(v.parse::()? * 1_000_000); - } - - // Billion. - if let Some(v) = s.strip_suffix("G") { - return Ok(v.parse::()? * 1_000_000_000); - } - - // Trillion, probably not necessary. - if let Some(v) = s.strip_suffix("T") { - return Ok(v.parse::()? * 1_000_000_000_000); - } - - // Simple value. - Ok(s.parse::()?) -} diff --git a/bin/datatool/src/util.rs b/bin/datatool/src/util.rs new file mode 100644 index 000000000..6132e16c8 --- /dev/null +++ b/bin/datatool/src/util.rs @@ -0,0 +1,470 @@ +//! Utility functions for Strata `datatool` binary. +//! +//! It contains functions for generating keys, parsing amounts, and constructing +//! network parameters. +//! These functions are called from the CLI's subcommands. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use bitcoin::{ + base58, + bip32::{Xpriv, Xpub}, + Network, +}; +use rand::{CryptoRng, RngCore}; +use strata_key_derivation::{ + operator::{convert_base_xpub_to_message_xpub, convert_base_xpub_to_wallet_xpub, OperatorKeys}, + sequencer::SequencerKeys, +}; +use strata_primitives::{ + block_credential, + buf::Buf32, + keys::ZeroizableXpriv, + operator::OperatorPubkeys, + params::{ProofPublishMode, RollupParams}, + proof::RollupVerifyingKey, +}; +use strata_sp1_guest_builder::GUEST_CHECKPOINT_VK_HASH_STR; +use zeroize::Zeroize; + +use crate::args::{ + CmdContext, SubcOpXpub, SubcParams, SubcSeqPrivkey, SubcSeqPubkey, SubcXpriv, Subcommand, +}; + +/// Sequencer key environment variable. +const SEQKEY_ENVVAR: &str = "STRATA_SEQ_KEY"; + +/// Operator key environment variable. +const OPKEY_ENVVAR: &str = "STRATA_OP_KEY"; + +/// The default network to use. +/// +/// Right now this is [`Network::Signet`]. +const DEFAULT_NETWORK: Network = Network::Signet; + +/// Resolves a [`Network`] from a string. +pub(super) fn resolve_network(arg: Option<&str>) -> anyhow::Result { + match arg { + Some("signet") => Ok(Network::Signet), + Some("regtest") => Ok(Network::Regtest), + Some(n) => anyhow::bail!("unsupported network option: {n}"), + None => Ok(DEFAULT_NETWORK), + } +} + +/// Executes a `gen*` subcommand. +pub(super) fn exec_subc(cmd: Subcommand, ctx: &mut CmdContext) -> anyhow::Result<()> { + match cmd { + Subcommand::Xpriv(subc) => exec_genxpriv(subc, ctx), + Subcommand::SeqPubkey(subc) => exec_genseqpubkey(subc, ctx), + Subcommand::SeqPrivkey(subc) => exec_genseqprivkey(subc, ctx), + Subcommand::OpXpub(subc) => exec_genopxpub(subc, ctx), + Subcommand::Params(subc) => exec_genparams(subc, ctx), + } +} + +/// Executes the `genxpriv` subcommand. +/// +/// Generates a new [`Xpriv`] that will [`Zeroize`](zeroize) on [`Drop`] and writes it to a file. +fn exec_genxpriv(cmd: SubcXpriv, ctx: &mut CmdContext) -> anyhow::Result<()> { + if cmd.path.exists() && !cmd.force { + anyhow::bail!("not overwriting file, add --force to overwrite"); + } + + let xpriv = gen_priv(&mut ctx.rng, ctx.bitcoin_network); + let mut buf = xpriv.encode(); + let mut s = base58::encode_check(&buf); + let result = fs::write(&cmd.path, s.as_bytes()); + + buf.zeroize(); + s.zeroize(); + + match result { + Ok(_) => Ok(()), + Err(_) => anyhow::bail!("failed to write to file {:?}", cmd.path), + } +} + +/// Executes the `genseqpubkey` subcommand. +/// +/// Generates the sequencer [`Xpub`] from the provided [`Xpriv`] +/// and prints it to stdout. +fn exec_genseqpubkey(cmd: SubcSeqPubkey, _ctx: &mut CmdContext) -> anyhow::Result<()> { + let Some(xpriv) = resolve_xpriv(&cmd.key_file, cmd.key_from_env, SEQKEY_ENVVAR)? else { + anyhow::bail!("privkey unset"); + }; + + let seq_keys = SequencerKeys::new(&xpriv)?; + let seq_xpub = seq_keys.derived_xpub(); + let raw_buf = seq_xpub.to_x_only_pub().serialize(); + let s = base58::encode_check(&raw_buf); + + println!("{s}"); + + Ok(()) +} + +/// Executes the `genseqprivkey` subcommand. +/// +/// Generates the sequencer [`Xpriv`] that will [`Zeroize`](zeroize) on [`Drop`] and prints it to +/// stdout. +fn exec_genseqprivkey(cmd: SubcSeqPrivkey, _ctx: &mut CmdContext) -> anyhow::Result<()> { + let Some(xpriv) = resolve_xpriv(&cmd.key_file, cmd.key_from_env, SEQKEY_ENVVAR)? else { + anyhow::bail!("privkey unset"); + }; + + let seq_keys = SequencerKeys::new(&xpriv)?; + let seq_xpriv = seq_keys.derived_xpriv(); + let mut raw_buf = seq_xpriv.to_priv().to_bytes(); + let mut s = base58::encode_check(&raw_buf); + + println!("{s}"); + + // Zeroize the buffers after printing. + raw_buf.zeroize(); + s.zeroize(); + + Ok(()) +} + +/// Executes the `genopxpub` subcommand. +/// +/// Generates the root xpub for an operator. +fn exec_genopxpub(cmd: SubcOpXpub, _ctx: &mut CmdContext) -> anyhow::Result<()> { + let Some(xpriv) = resolve_xpriv(&cmd.key_file, cmd.key_from_env, OPKEY_ENVVAR)? else { + anyhow::bail!("privkey unset"); + }; + + let op_keys = OperatorKeys::new(&xpriv)?; + let op_base_xpub = op_keys.base_xpub(); + let raw_buf = op_base_xpub.encode(); + let s = base58::encode_check(&raw_buf); + + println!("{s}"); + + Ok(()) +} + +/// Executes the `genparams` subcommand. +/// +/// Generates the params for a Strata network. +/// Either writes to a file or prints to stdout depending on the provided options. +fn exec_genparams(cmd: SubcParams, ctx: &mut CmdContext) -> anyhow::Result<()> { + // Parse the sequencer key, trimming whitespace for convenience. + let seqkey = match cmd.seqkey.as_ref().map(|s| s.trim()) { + Some(seqkey) => { + let buf = match base58::decode_check(seqkey) { + Ok(v) => v, + Err(e) => { + anyhow::bail!("failed to parse sequencer key '{seqkey}': {e}"); + } + }; + + let Ok(buf) = Buf32::try_from(buf.as_slice()) else { + anyhow::bail!("invalid sequencer key '{seqkey}' (must be 32 bytes)"); + }; + + Some(buf) + } + None => None, + }; + + // Parse each of the operator keys. + let mut opkeys = Vec::new(); + + if let Some(opkeys_path) = cmd.opkeys { + let opkeys_str = fs::read_to_string(opkeys_path)?; + + for l in opkeys_str.lines() { + // skip lines that are empty or look like comments + if l.trim().is_empty() || l.starts_with("#") { + continue; + } + + opkeys.push(parse_xpub(l)?); + } + } + + for k in cmd.opkey { + opkeys.push(parse_xpub(&k)?); + } + + // Parse the deposit size str. + let deposit_sats = cmd + .deposit_sats + .map(|s| parse_abbr_amt(&s)) + .transpose()? + .unwrap_or(1_000_000_000); + + // Parse the checkpoint verification key. + let rollup_vk: Buf32 = GUEST_CHECKPOINT_VK_HASH_STR + .parse() + .expect("invalid checkpoint verifier key hash"); + + let config = ParamsConfig { + name: cmd.name.unwrap_or_else(|| "strata-testnet".to_string()), + bitcoin_network: ctx.bitcoin_network, + // TODO make these consts + block_time_sec: cmd.block_time.unwrap_or(15), + epoch_slots: cmd.epoch_slots.unwrap_or(64), + genesis_trigger: cmd.genesis_trigger_height.unwrap_or(100), + seqkey, + opkeys, + rollup_vk, + // TODO make a const + deposit_sats, + proof_timeout: cmd.proof_timeout, + }; + + let params = construct_params(config); + let params_buf = serde_json::to_string_pretty(¶ms)?; + + if let Some(out_path) = &cmd.output { + fs::write(out_path, params_buf)?; + eprintln!("wrote to file {out_path:?}"); + } else { + println!("{params_buf}"); + } + + Ok(()) +} + +/// Generates a new [`Xpriv`] that will [`Zeroize`](zeroize) on [`Drop`]. +/// +/// # Notes +/// +/// Takes a mutable reference to an RNG to allow flexibility in testing. +/// The actual generation requires a high-entropy source like [`OsRng`](rand::rngs::OsRng) +/// to securely generate extended private keys. +fn gen_priv(rng: &mut R, net: Network) -> ZeroizableXpriv { + let mut seed = [0u8; 32]; + rng.fill_bytes(&mut seed); + let mut xpriv = Xpriv::new_master(net, &seed).expect("valid seed"); + let zeroizable_xpriv: ZeroizableXpriv = xpriv.into(); + + // Zeroize the seed after generating the xpriv. + seed.zeroize(); + // Zeroize the xpriv after generating it. + // + // NOTE: `zeroizable_xpriv` is zeroized on drop. + xpriv.private_key.non_secure_erase(); + + zeroizable_xpriv +} + +/// Reads an [`Xpriv`] from file as a string and verifies the checksum. +/// +/// # Notes +/// +/// This [`Xpriv`] will [`Zeroize`](zeroize) on [`Drop`]. +fn read_xpriv(path: &Path) -> anyhow::Result { + let mut raw_buf = fs::read(path)?; + let str_buf: &str = std::str::from_utf8(&raw_buf)?; + let mut buf = base58::decode_check(str_buf)?; + + // Parse into a ZeroizableXpriv. + let xpriv = Xpriv::decode(&buf)?; + let zeroizable_xpriv: ZeroizableXpriv = xpriv.into(); + + // Zeroize the buffers after parsing. + // + // NOTE: `zeroizable_xpriv` is zeroized on drop; + // and `str_buf` is a reference to `raw_buf`. + raw_buf.zeroize(); + buf.zeroize(); + + Ok(zeroizable_xpriv) +} + +/// Parses an [`Xpriv`] from environment variable. +/// +/// # Notes +/// +/// This [`Xpriv`] will [`Zeroize`](zeroize) on [`Drop`]. +fn parse_xpriv_from_env(env: &'static str) -> anyhow::Result> { + let mut env_val = match std::env::var(env) { + Ok(v) => v, + Err(_) => anyhow::bail!("got --key-from-env but {env} not set or invalid"), + }; + + let mut buf = base58::decode_check(&env_val)?; + + // Parse into a ZeroizableXpriv. + let mut xpriv = Xpriv::decode(&buf)?; + let zeroizable_xpriv: ZeroizableXpriv = xpriv.into(); + + // Zeroize the buffers after parsing. + // + // NOTE: `zeroizable_xpriv` is zeroized on drop. + env_val.zeroize(); + buf.zeroize(); + xpriv.private_key.non_secure_erase(); + + Ok(Some(zeroizable_xpriv)) +} + +/// Parses an [`Xpriv`] from file path. +/// +/// # Notes +/// +/// This [`Xpriv`] will [`Zeroize`](zeroize) on [`Drop`]. +fn parse_xpriv_from_path(path: &Path) -> anyhow::Result> { + Ok(Some(read_xpriv(path)?)) +} + +/// Resolves an [`Xpriv`] from the file path (if provided) or environment variable (if +/// `--key-from-env` set). Only one source should be specified. +/// +/// Priority: +/// +/// 1. File path (if provided with path argument) +/// 2. Environment variable (if --key-from-env flag is set) +/// +/// # Notes +/// +/// This [`Xpriv`] will [`Zeroize`](zeroize) on [`Drop`]. +fn resolve_xpriv( + path: &Option, + from_env: bool, + env: &'static str, +) -> anyhow::Result> { + match (path, from_env) { + (Some(_), true) => anyhow::bail!("got key path and --key-from-env, pick a lane"), + (Some(path), false) => parse_xpriv_from_path(path), + (None, true) => parse_xpriv_from_env(env), + _ => Ok(None), + } +} + +/// Inputs for constructing the network parameters. +pub struct ParamsConfig { + /// Name of the network. + name: String, + /// Network to use. + #[allow(unused)] + bitcoin_network: Network, + /// Block time in seconds. + block_time_sec: u64, + /// Number of slots in an epoch. + epoch_slots: u32, + /// Height at which the genesis block is triggered. + genesis_trigger: u64, + /// Sequencer's key. + seqkey: Option, + /// Operators' keys. + opkeys: Vec, + /// Verifier's key. + rollup_vk: Buf32, + /// Amount of sats to deposit. + deposit_sats: u64, + /// Timeout for proofs. + proof_timeout: Option, +} + +/// Constructs the parameters for a Strata network. +// TODO convert this to also initialize the sync params +fn construct_params(config: ParamsConfig) -> RollupParams { + let cr = config + .seqkey + .map(block_credential::CredRule::SchnorrKey) + .unwrap_or(block_credential::CredRule::Unchecked); + + let opkeys = config + .opkeys + .into_iter() + .map(|xpk| { + let message_xpub = convert_base_xpub_to_message_xpub(&xpk); + let wallet_xpub = convert_base_xpub_to_wallet_xpub(&xpk); + let message_key_buf = message_xpub.to_x_only_pub().serialize().into(); + let wallet_key_buf = wallet_xpub.to_x_only_pub().serialize().into(); + OperatorPubkeys::new(message_key_buf, wallet_key_buf) + }) + .collect::>(); + + // TODO add in bitcoin network + RollupParams { + rollup_name: config.name, + block_time: config.block_time_sec * 1000, + cred_rule: cr, + // TODO do we want to remove this? + horizon_l1_height: config.genesis_trigger / 2, + genesis_l1_height: config.genesis_trigger, + operator_config: strata_primitives::params::OperatorConfig::Static(opkeys), + // TODO make configurable + evm_genesis_block_hash: + "0x37ad61cff1367467a98cf7c54c4ac99e989f1fbb1bc1e646235e90c065c565ba" + .parse() + .unwrap(), + evm_genesis_block_state_root: + "0x351714af72d74259f45cd7eab0b04527cd40e74836a45abcae50f92d919d988f" + .parse() + .unwrap(), + // TODO make configurable + l1_reorg_safe_depth: 4, + target_l2_batch_size: config.epoch_slots as u64, + address_length: 20, + deposit_amount: config.deposit_sats, + rollup_vk: RollupVerifyingKey::SP1VerifyingKey(config.rollup_vk), + // TODO make configurable + dispatch_assignment_dur: 64, + proof_publish_mode: config + .proof_timeout + .map(|t| ProofPublishMode::Timeout(t as u64)) + .unwrap_or(ProofPublishMode::Strict), + // TODO make configurable + max_deposits_in_block: 16, + network: config.bitcoin_network, + } +} + +/// Parses an [`Xpub`] from [`&str`], richly generating [`anyhow::Result`]s from +/// it. +fn parse_xpub(s: &str) -> anyhow::Result { + let Ok(buf) = base58::decode_check(s) else { + anyhow::bail!("failed to parse key: {s}"); + }; + + let Ok(xpk) = Xpub::decode(&buf) else { + anyhow::bail!("failed to decode key: {s}"); + }; + + Ok(xpk) +} + +/// Parses an abbreviated amount string. +/// +/// User may of may not use suffixes to denote the amount. +/// +/// # Possible suffixes (case sensitive) +/// +/// - `K` for thousand. +/// - `M` for million. +/// - `G` for billion. +/// - `T` for trillion. +fn parse_abbr_amt(s: &str) -> anyhow::Result { + // Thousand. + if let Some(v) = s.strip_suffix("K") { + return Ok(v.parse::()? * 1000); + } + + // Million. + if let Some(v) = s.strip_suffix("M") { + return Ok(v.parse::()? * 1_000_000); + } + + // Billion. + if let Some(v) = s.strip_suffix("G") { + return Ok(v.parse::()? * 1_000_000_000); + } + + // Trillion, probably not necessary. + if let Some(v) = s.strip_suffix("T") { + return Ok(v.parse::()? * 1_000_000_000_000); + } + + // Simple value. + Ok(s.parse::()?) +} diff --git a/bin/strata-cli/Cargo.toml b/bin/strata-cli/Cargo.toml index d5c294b25..ba83ed077 100644 --- a/bin/strata-cli/Cargo.toml +++ b/bin/strata-cli/Cargo.toml @@ -48,7 +48,7 @@ strata-bridge-tx-builder.workspace = true strata-primitives.workspace = true terrors.workspace = true tokio.workspace = true -zeroize = { version = "1.8.1", features = ["derive"] } +zeroize.workspace = true zxcvbn = "3.1.0" # sha2 fails to compile on windows with the "asm" feature diff --git a/bin/strata-client/Cargo.toml b/bin/strata-client/Cargo.toml index cab8dc28f..703e1faa0 100644 --- a/bin/strata-client/Cargo.toml +++ b/bin/strata-client/Cargo.toml @@ -16,6 +16,7 @@ strata-consensus-logic.workspace = true strata-db.workspace = true strata-eectl.workspace = true strata-evmexec.workspace = true +strata-key-derivation.workspace = true strata-primitives.workspace = true strata-rocksdb.workspace = true strata-rpc-api.workspace = true @@ -51,6 +52,7 @@ threadpool.workspace = true tokio.workspace = true toml.workspace = true tracing.workspace = true +zeroize.workspace = true [dev-dependencies] arbitrary.workspace = true diff --git a/bin/strata-client/src/helpers.rs b/bin/strata-client/src/helpers.rs index 740d8b85f..ad22e15aa 100644 --- a/bin/strata-client/src/helpers.rs +++ b/bin/strata-client/src/helpers.rs @@ -2,12 +2,7 @@ use std::{fs, path::Path, sync::Arc, time::Duration}; use alloy_rpc_types::engine::JwtSecret; use anyhow::Context; -use bitcoin::{ - base58, - bip32::{Xpriv, Xpub}, - secp256k1::SECP256K1, - Address, Network, -}; +use bitcoin::{base58, bip32::Xpriv, Address, Network}; use format_serde_error::SerdeError; use rockbound::{rocksdb, OptimisticTransactionDB}; use strata_btcio::rpc::{traits::Wallet, BitcoinClient}; @@ -17,8 +12,10 @@ use strata_consensus_logic::{ }; use strata_db::{database::CommonDatabase, traits::Database}; use strata_evmexec::{engine::RpcExecEngineCtl, fork_choice_state_initial, EngineRpcClient}; +use strata_key_derivation::sequencer::SequencerKeys; use strata_primitives::{ buf::Buf32, + keys::ZeroizableXpriv, l1::L1Status, params::{Params, RollupParams}, }; @@ -31,8 +28,9 @@ use strata_status::StatusChannel; use strata_storage::L2BlockManager; use tokio::runtime::Runtime; use tracing::*; +use zeroize::Zeroize; -use crate::{args::Args, config::Config, errors::InitError, keyderiv, network}; +use crate::{args::Args, config::Config, errors::InitError, network}; pub type CommonDb = CommonDatabase; @@ -184,17 +182,21 @@ pub fn load_seqkey(path: &Path) -> anyhow::Result { let str_buf = std::str::from_utf8(&raw_buf)?; debug!(?path, "loading sequencer root key"); let buf = base58::decode_check(str_buf)?; - let root_xpriv = Xpriv::decode(&buf)?; + let master_xpriv = ZeroizableXpriv::new(Xpriv::decode(&buf)?); // Actually do the key derivation from the root key and then derive the pubkey from that. - let seq_xpriv = keyderiv::derive_seq_xpriv(&root_xpriv)?; - let seq_sk = Buf32::from(seq_xpriv.private_key.secret_bytes()); - let seq_xpub = Xpub::from_priv(SECP256K1, &seq_xpriv); + let seq_keys = SequencerKeys::new(&master_xpriv)?; + let seq_xpriv = seq_keys.derived_xpriv(); + let mut seq_sk = Buf32::from(seq_xpriv.private_key.secret_bytes()); + let seq_xpub = seq_keys.derived_xpub(); let seq_pk = seq_xpub.to_x_only_pub().serialize(); let ik = IdentityKey::Sequencer(seq_sk); let ident = Identity::Sequencer(Buf32::from(seq_pk)); + // Zeroize the Buf32 representation of the Xpriv. + seq_sk.zeroize(); + // Changed this to the pubkey so that we don't just log our privkey. debug!(?ident, "ready to sign as sequencer"); diff --git a/bin/strata-client/src/keyderiv.rs b/bin/strata-client/src/keyderiv.rs deleted file mode 100644 index 1f392d9ea..000000000 --- a/bin/strata-client/src/keyderiv.rs +++ /dev/null @@ -1,28 +0,0 @@ -#![allow(unused)] // don't want to get disorganized -//! Key derivation logic, copied from datatool. Pending reorganizataion into -//! its own crate. - -use bitcoin::{ - bip32::{ChildNumber, DerivationPath, Xpriv}, - secp256k1::SECP256K1, -}; - -// TODO move some of these into a keyderiv crate -const DERIV_BASE_IDX: u32 = 56; -const DERIV_SEQ_IDX: u32 = 10; -const DERIV_OP_IDX: u32 = 20; -const DERIV_OP_SIGNING_IDX: u32 = 100; -const DERIV_OP_WALLET_IDX: u32 = 101; - -fn derive_strata_scheme_xpriv(master: &Xpriv, last: u32) -> anyhow::Result { - let derivation_path = DerivationPath::master().extend([ - ChildNumber::from_hardened_idx(DERIV_BASE_IDX).unwrap(), - ChildNumber::from_hardened_idx(last).unwrap(), - ]); - Ok(master.derive_priv(SECP256K1, &derivation_path)?) -} - -/// Derives the sequencer xpriv. -pub fn derive_seq_xpriv(master: &Xpriv) -> anyhow::Result { - derive_strata_scheme_xpriv(master, DERIV_SEQ_IDX) -} diff --git a/bin/strata-client/src/main.rs b/bin/strata-client/src/main.rs index e6f2731b5..19248a6ef 100644 --- a/bin/strata-client/src/main.rs +++ b/bin/strata-client/src/main.rs @@ -47,7 +47,6 @@ mod config; mod errors; mod extractor; mod helpers; -mod keyderiv; mod l1_reader; mod network; mod rpc_client; diff --git a/crates/key-derivation/Cargo.toml b/crates/key-derivation/Cargo.toml new file mode 100644 index 000000000..7847b8694 --- /dev/null +++ b/crates/key-derivation/Cargo.toml @@ -0,0 +1,20 @@ +[package] +edition = "2021" +name = "strata-key-derivation" +version = "0.1.0" + +[lints] +rust.rust_2018_idioms = { level = "deny", priority = -1 } +rust.unused_crate_dependencies = "deny" +rust.unused_must_use = "deny" + +[dependencies] +strata-primitives.workspace = true + +bitcoin = { workspace = true, features = ["rand-std"] } +secp256k1.workspace = true +thiserror.workspace = true +zeroize = { workspace = true, optional = true } + +[features] +default = ["zeroize"] diff --git a/crates/key-derivation/src/error.rs b/crates/key-derivation/src/error.rs new file mode 100644 index 000000000..f4fe0d446 --- /dev/null +++ b/crates/key-derivation/src/error.rs @@ -0,0 +1,23 @@ +//! Error types for key derivation. + +use std::fmt::{Debug, Display, Formatter, Result}; + +use bitcoin::bip32; +use thiserror::Error; + +#[derive(Error, Debug, Clone)] +pub enum KeyError { + /// An error from the [`bip32`] module. + /// + /// This means that the [`Xpriv`](bip32::Xpriv) + /// is not a valid extended private key or the + /// [`DerivationPath`](bip32::DerivationPath) + /// is invalid. + Bip32Error(#[from] bip32::Error), +} + +impl Display for KeyError { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + Debug::fmt(self, f) + } +} diff --git a/crates/key-derivation/src/lib.rs b/crates/key-derivation/src/lib.rs new file mode 100644 index 000000000..57fd20b11 --- /dev/null +++ b/crates/key-derivation/src/lib.rs @@ -0,0 +1,11 @@ +//! Key derivation for Strata +//! +//! This crate contains the key derivation logic for Strata. +//! +//! It is split into two modules: +//! - `operator`: Key derivation for bridge operators +//! - `sequencer`: Key derivation for sequencer + +pub mod error; +pub mod operator; +pub mod sequencer; diff --git a/crates/key-derivation/src/operator.rs b/crates/key-derivation/src/operator.rs new file mode 100644 index 000000000..24f7bcd2b --- /dev/null +++ b/crates/key-derivation/src/operator.rs @@ -0,0 +1,453 @@ +//! Key derivation for Strata bridge operators +//! +//! Bridge operators guarantee the security assumptions of the Strata BitVM-based +//! bridge by enforcing that all peg-ins and peg-outs are valid. +//! +//! Operators are responsible for their own keys and master [`Xpriv`] is not +//! shared between operators. Hence, this crate has a BYOK (Bring Your Own Key) design. +//! +//! They use a set of keys to sign messages and bitcoin transactions. +//! The keys are derived from a master [`Xpriv`] +//! using a [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) +//! HD derivation path. +//! +//! The derivation paths are: +//! +//! - `m/56'/20'/100` for the message signing key +//! - `m/56'/20'/101` for the wallet transaction signing key + +use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; +use secp256k1::SECP256K1; +use strata_primitives::constants::{ + STRATA_OPERATOR_BASE_DERIVATION_PATH, STRATA_OPERATOR_MESSAGE_IDX, STRATA_OPERATOR_WALLET_IDX, + STRATA_OP_MESSAGE_DERIVATION_PATH, STRATA_OP_WALLET_DERIVATION_PATH, +}; +#[cfg(feature = "zeroize")] +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::error::KeyError; + +/// Operator's message signing and wallet transaction signing _private_ keys. +#[derive(Debug, Clone)] +pub struct OperatorKeys { + /// Operator's master [`Xpriv`]. + master: Xpriv, + + /// Operator's base [`Xpriv`]. + /// + /// # Notes + /// + /// This is the [`Xpriv`] that is generated from only hardened paths. + base: Xpriv, + + /// Operator's message signing [`Xpriv`]. + message: Xpriv, + + /// Operator's wallet transaction signing [`Xpriv`]. + wallet: Xpriv, +} + +impl OperatorKeys { + /// Creates a new [`OperatorKeys`] from a master [`Xpriv`]. + pub fn new(master: &Xpriv) -> Result { + let base_xpriv = master.derive_priv(SECP256K1, &*STRATA_OPERATOR_BASE_DERIVATION_PATH)?; + let message_xpriv = master.derive_priv(SECP256K1, &*STRATA_OP_MESSAGE_DERIVATION_PATH)?; + let wallet_xpriv = master.derive_priv(SECP256K1, &*STRATA_OP_WALLET_DERIVATION_PATH)?; + + Ok(Self { + master: *master, + base: base_xpriv, + message: message_xpriv, + wallet: wallet_xpriv, + }) + } + + /// Operator's master [`Xpriv`]. + pub fn master_xpriv(&self) -> &Xpriv { + &self.master + } + + /// Operator's base [`Xpriv`]. + /// + /// # Notes + /// + /// This is the [`Xpriv`] that is generated from only hardened paths from the master [`Xpriv`]. + pub fn base_xpriv(&self) -> &Xpriv { + &self.base + } + + /// Operator's wallet transaction signing [`Xpriv`]. + pub fn wallet_xpriv(&self) -> &Xpriv { + &self.wallet + } + + /// Operator's message signing [`Xpriv`]. + pub fn message_xpriv(&self) -> &Xpriv { + &self.message + } + + /// Operator's master [`Xpub`]. + pub fn master_xpub(&self) -> Xpub { + Xpub::from_priv(SECP256K1, &self.master) + } + + /// Operator's base [`Xpub`] + /// + /// Infallible conversion from [`Xpriv`] to [`Xpub`] according to + /// [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) + /// + /// # Notes + /// + /// This is the [`Xpub`] that is generated from only hardened paths from the master [`Xpriv`]. + pub fn base_xpub(&self) -> Xpub { + Xpub::from_priv(SECP256K1, &self.base) + } + + /// Operator's message signing [`Xpub`]. + /// + /// Infallible according to + /// [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) + pub fn message_xpub(&self) -> Xpub { + Xpub::from_priv(SECP256K1, &self.message) + } + + /// Operator's wallet transaction signing [`Xpub`]. + /// + /// Infallible conversion from [`Xpriv`] to [`Xpub`] according to + /// [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) + pub fn wallet_xpub(&self) -> Xpub { + Xpub::from_priv(SECP256K1, &self.wallet) + } +} + +// Manual Drop implementation to zeroize keys on drop. +impl Drop for OperatorKeys { + fn drop(&mut self) { + #[cfg(feature = "zeroize")] + self.zeroize(); + } +} + +#[cfg(feature = "zeroize")] +impl Zeroize for OperatorKeys { + #[inline] + fn zeroize(&mut self) { + let Self { + master, + base, + message, + wallet, + } = self; + + // # Security note + // + // Going over all possible "zeroizable" fields. + // What we cannot zeroize is only: + // + // - Network: enum + // + // These are fine to leave as they are since they are public parameters, + // and not secret values. + // + // NOTE: `Xpriv.private_key` (`SecretKey`) `non_secure_erase` writes `1`s to the memory. + + // Zeroize master components + master.depth.zeroize(); + { + let fingerprint: &mut [u8; 4] = master.parent_fingerprint.as_mut(); + fingerprint.zeroize(); + } + master.private_key.non_secure_erase(); + { + let chaincode: &mut [u8; 32] = master.chain_code.as_mut(); + chaincode.zeroize(); + } + let raw_ptr = &mut master.child_number as *mut ChildNumber; + // SAFETY: `master.child_number` is a valid enum variant + // and will not be accessed after zeroization. + // Also there are only two possible variants that will + // always have an `index` which is a `u32`. + // Note that `ChildNumber` does not have the `#[non_exhaustive]` + // attribute. + unsafe { + *raw_ptr = if master.child_number.is_normal() { + ChildNumber::Normal { index: 0 } + } else { + ChildNumber::Hardened { index: 0 } + }; + } + + // Zeroize base components + base.depth.zeroize(); + { + let fingerprint: &mut [u8; 4] = base.parent_fingerprint.as_mut(); + fingerprint.zeroize(); + } + base.private_key.non_secure_erase(); + { + let chaincode: &mut [u8; 32] = base.chain_code.as_mut(); + chaincode.zeroize(); + } + let raw_ptr = &mut base.child_number as *mut ChildNumber; + // SAFETY: `base.child_number` is a valid enum variant + // and will not be accessed after zeroization. + // Also there are only two possible variants that will + // always have an `index` which is a `u32`. + // Note that `ChildNumber` does not have the `#[non_exhaustive]` + // attribute. + unsafe { + *raw_ptr = if base.child_number.is_normal() { + ChildNumber::Normal { index: 0 } + } else { + ChildNumber::Hardened { index: 0 } + }; + } + + // Zeroize message components + message.depth.zeroize(); + { + let fingerprint: &mut [u8; 4] = message.parent_fingerprint.as_mut(); + fingerprint.zeroize(); + } + message.private_key.non_secure_erase(); + { + let chaincode: &mut [u8; 32] = message.chain_code.as_mut(); + chaincode.zeroize(); + } + let raw_ptr = &mut message.child_number as *mut ChildNumber; + // SAFETY: `message.child_number` is a valid enum variant + // and will not be accessed after zeroization. + // Also there are only two possible variants that will + // always have an `index` which is a `u32`. + // Note that `ChildNumber` does not have the `#[non_exhaustive]` + // attribute. + unsafe { + *raw_ptr = if message.child_number.is_normal() { + ChildNumber::Normal { index: 0 } + } else { + ChildNumber::Hardened { index: 0 } + }; + } + + // Zeroize wallet components + wallet.depth.zeroize(); + { + let fingerprint: &mut [u8; 4] = wallet.parent_fingerprint.as_mut(); + fingerprint.zeroize(); + } + wallet.private_key.non_secure_erase(); + { + let chaincode: &mut [u8; 32] = wallet.chain_code.as_mut(); + chaincode.zeroize(); + } + let raw_ptr = &mut wallet.child_number as *mut ChildNumber; + // SAFETY: `wallet.child_number` is a valid enum variant + // and will not be accessed after zeroization. + // Also there are only two possible variants that will + // always have an `index` which is a `u32`. + // Note that `ChildNumber` does not have the `#[non_exhaustive]` + // attribute. + unsafe { + *raw_ptr = if wallet.child_number.is_normal() { + ChildNumber::Normal { index: 0 } + } else { + ChildNumber::Hardened { index: 0 } + }; + } + } +} + +#[cfg(feature = "zeroize")] +impl ZeroizeOnDrop for OperatorKeys {} + +/// Converts the base [`Xpub`] to the message [`Xpub`]. +pub fn convert_base_xpub_to_message_xpub(base_xpub: &Xpub) -> Xpub { + let message_partial_path = ChildNumber::from_normal_idx(STRATA_OPERATOR_MESSAGE_IDX) + .expect("unfallible as long MESSAGE_IDX is not changed"); + base_xpub + .derive_pub(SECP256K1, &message_partial_path) + .expect("unfallible") +} + +/// Converts the base [`Xpub`] to the wallet [`Xpub`]. +pub fn convert_base_xpub_to_wallet_xpub(base_xpub: &Xpub) -> Xpub { + let wallet_partial_path = ChildNumber::from_normal_idx(STRATA_OPERATOR_WALLET_IDX) + .expect("unfallible as long WALLET_IDX is not changed"); + base_xpub + .derive_pub(SECP256K1, &wallet_partial_path) + .expect("unfallible") +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, sync::LazyLock}; + + use bitcoin::{ + absolute, bip32::DerivationPath, consensus, hashes::Hash, psbt::Input, + transaction::Version, Address, Amount, OutPoint, Psbt, Sequence, TapSighashType, + Transaction, TxIn, TxOut, Txid, Witness, + }; + + use super::*; + + // What's better than bacon? bacon^24 of course + // Thix xpriv was generated by the bacon^24 mnemonic + // Don't use this in production! + const XPRIV_STR: &str = "tprv8ZgxMBicQKsPeh9dSitM82FU7Fz3ZgPkKmmovAr2aqwauAMVgjcEkZBb2etBtRPZ8XYVm7shxcKwVaDus7T5kauJXVsqAfzM4Tty13rRjAG"; + static XPRIV: LazyLock = LazyLock::new(|| XPRIV_STR.parse().unwrap()); + + // The first address derived from the xpriv above using a `tr()` descriptor. + const ADDRESS: &str = "bcrt1p729l9680ht3zf7uhl6pgdrlhfp9r29cwajr5jk3k05fer62763fscz0w4s"; + // The second address derived from the xpriv above using a `tr()` descriptor. + const DEST_ADDRESS: &str = "bcrt1p5uhmu40t5yl97kr95s2m4sr8a9f3af2meqeefkx33symwex3wfqqfe77m3"; + + // Dummy values for the test. + const DUMMY_UTXO_AMOUNT: Amount = Amount::from_sat(20_000_000); + const SPEND_AMOUNT: Amount = Amount::from_sat(19_999_000); // 1000 sat fee. + + #[test] + fn test_operator_keys() { + // Parse stuff + let address = ADDRESS.parse::>().unwrap().assume_checked(); + let dest_address = DEST_ADDRESS.parse::>().unwrap().assume_checked(); + + // Create the operator keys + let operator_keys = OperatorKeys::new(&XPRIV).unwrap(); + let wallet_key = operator_keys.wallet_xpriv(); + let wallet_pubkey = operator_keys.wallet_xpub(); + let wallet_fingerprint = wallet_pubkey.fingerprint(); + let derivation_path = DerivationPath::master(); + let (x_only_pubkey, _) = wallet_pubkey.public_key.x_only_public_key(); + + // Create a dummy transaction with a single input and output. + let outpoint = OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }; + let txin = TxIn { + previous_output: outpoint, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }; + let txout = TxOut { + value: SPEND_AMOUNT, + script_pubkey: dest_address.script_pubkey(), + }; + let previous_txout = TxOut { + value: DUMMY_UTXO_AMOUNT, + script_pubkey: address.script_pubkey(), + }; + + // Create the unsigned transaction + let transaction = Transaction { + version: Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![txin], + output: vec![txout], + }; + + // Create the PSBT + let mut psbt = Psbt::from_unsigned_tx(transaction).expect("could not create PSBT"); + let ty = TapSighashType::All.into(); + let origins = BTreeMap::from([( + x_only_pubkey, + (vec![], (wallet_fingerprint, derivation_path)), + )]); + + // Add the input to the PSBT + psbt.inputs = vec![Input { + witness_utxo: Some(previous_txout), + tap_key_origins: origins, + tap_internal_key: Some(x_only_pubkey), + sighash_type: Some(ty), + ..Default::default() + }]; + + // Sign the PSBT + psbt.sign(wallet_key, SECP256K1) + .expect("could not sign PSBT"); + + // Finalize the PSBT + psbt.inputs[0].final_script_witness = Some(Witness::p2tr_key_spend( + &psbt.inputs[0].tap_key_sig.unwrap(), + )); + // Clear all the data fields as per the spec. + psbt.inputs[0].partial_sigs = BTreeMap::new(); + psbt.inputs[0].sighash_type = None; + psbt.inputs[0].redeem_script = None; + psbt.inputs[0].witness_script = None; + psbt.inputs[0].bip32_derivation = BTreeMap::new(); + + // Extract the transaction and serialize it + let signed_tx = psbt.extract_tx().expect("valid transaction"); + let serialized_signed_tx = consensus::encode::serialize_hex(&signed_tx); + println!("serialized_signed_tx: {}", serialized_signed_tx); + } + + #[test] + #[cfg(feature = "zeroize")] + fn test_zeroize() { + use bitcoin::Network; + + let master = Xpriv::new_master(Network::Regtest, &[2u8; 32]).unwrap(); + let mut keys = OperatorKeys::new(&master).unwrap(); + + // Store original values + let master_chaincode = *keys.master_xpriv().chain_code.as_bytes(); + let base_chaincode = *keys.base_xpriv().chain_code.as_bytes(); + let message_chaincode = *keys.message_xpriv().chain_code.as_bytes(); + let wallet_chaincode = *keys.wallet_xpriv().chain_code.as_bytes(); + + // Verify data exists + assert_ne!(master_chaincode, [0u8; 32]); + assert_ne!(base_chaincode, [0u8; 32]); + assert_ne!(message_chaincode, [0u8; 32]); + assert_ne!(wallet_chaincode, [0u8; 32]); + + // Manually zeroize + keys.zeroize(); + + // Verify fields are zeroed + // NOTE: SecretKey::non_secure_erase writes `1`s to the memory. + assert_eq!(keys.master_xpriv().private_key.secret_bytes(), [1u8; 32]); + assert_eq!(keys.base_xpriv().private_key.secret_bytes(), [1u8; 32]); + assert_eq!(keys.message_xpriv().private_key.secret_bytes(), [1u8; 32]); + assert_eq!(keys.wallet_xpriv().private_key.secret_bytes(), [1u8; 32]); + + assert_eq!(*keys.master_xpriv().chain_code.as_bytes(), [0u8; 32]); + assert_eq!(*keys.base_xpriv().chain_code.as_bytes(), [0u8; 32]); + assert_eq!(*keys.message_xpriv().chain_code.as_bytes(), [0u8; 32]); + assert_eq!(*keys.wallet_xpriv().chain_code.as_bytes(), [0u8; 32]); + + assert_eq!(*keys.master_xpriv().parent_fingerprint.as_bytes(), [0u8; 4]); + assert_eq!(*keys.base_xpriv().parent_fingerprint.as_bytes(), [0u8; 4]); + assert_eq!( + *keys.message_xpriv().parent_fingerprint.as_bytes(), + [0u8; 4] + ); + assert_eq!(*keys.wallet_xpriv().parent_fingerprint.as_bytes(), [0u8; 4]); + + assert_eq!(keys.master_xpriv().depth, 0); + assert_eq!(keys.base_xpriv().depth, 0); + assert_eq!(keys.message_xpriv().depth, 0); + assert_eq!(keys.wallet_xpriv().depth, 0); + + // Check if child numbers are zeroed while maintaining their hardened/normal status + match keys.master_xpriv().child_number { + ChildNumber::Normal { index } => assert_eq!(index, 0), + ChildNumber::Hardened { index } => assert_eq!(index, 0), + } + match keys.base_xpriv().child_number { + ChildNumber::Normal { index } => assert_eq!(index, 0), + ChildNumber::Hardened { index } => assert_eq!(index, 0), + } + match keys.message_xpriv().child_number { + ChildNumber::Normal { index } => assert_eq!(index, 0), + ChildNumber::Hardened { index } => assert_eq!(index, 0), + } + match keys.wallet_xpriv().child_number { + ChildNumber::Normal { index } => assert_eq!(index, 0), + ChildNumber::Hardened { index } => assert_eq!(index, 0), + } + } +} diff --git a/crates/key-derivation/src/sequencer.rs b/crates/key-derivation/src/sequencer.rs new file mode 100644 index 000000000..7c0e1496d --- /dev/null +++ b/crates/key-derivation/src/sequencer.rs @@ -0,0 +1,190 @@ +//! Key derivation for Strata sequencer + +use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; +use secp256k1::SECP256K1; +use strata_primitives::constants::STRATA_SEQUENCER_DERIVATION_PATH; +#[cfg(feature = "zeroize")] +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::error::KeyError; + +/// The Strata sequencer's master, and derived _private_ keys. +#[derive(Debug, Clone)] +pub struct SequencerKeys { + /// Sequencer's master [`Xpriv`]. + master: Xpriv, + + /// Sequencer's derived [`Xpriv`]. + derived: Xpriv, +} + +impl SequencerKeys { + /// Creates a new [`SequencerKeys`] from a master [`Xpriv`]. + pub fn new(master: &Xpriv) -> Result { + let derived = master.derive_priv(SECP256K1, &*STRATA_SEQUENCER_DERIVATION_PATH)?; + + Ok(Self { + master: *master, + derived, + }) + } + + /// Sequencer's master [`Xpriv`]. + pub fn master_xpriv(&self) -> &Xpriv { + &self.master + } + + /// Sequencer's derived [`Xpriv`]. + pub fn derived_xpriv(&self) -> &Xpriv { + &self.derived + } + + /// Sequencer's master [`Xpub`]. + /// + /// Infallible conversion from [`Xpriv`] to [`Xpub`] according to + /// [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) + pub fn master_xpub(&self) -> Xpub { + Xpub::from_priv(SECP256K1, &self.master) + } + + /// Sequencer's derived [`Xpub`]. + /// + /// Infallible conversion from [`Xpriv`] to [`Xpub`] according to + /// [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) + pub fn derived_xpub(&self) -> Xpub { + Xpub::from_priv(SECP256K1, &self.derived) + } +} + +// Manual Drop implementation to zeroize keys on drop. +impl Drop for SequencerKeys { + fn drop(&mut self) { + #[cfg(feature = "zeroize")] + self.zeroize(); + } +} + +#[cfg(feature = "zeroize")] +impl Zeroize for SequencerKeys { + #[inline] + fn zeroize(&mut self) { + let Self { master, derived } = self; + + // # Security note + // + // Going over all possible "zeroizable" fields. + // What we cannot zeroize is only: + // + // - Network: enum + // + // These are fine to leave as they are since they are public parameters, + // and not secret values. + // + // NOTE: `Xpriv.private_key` (`SecretKey`) `non_secure_erase` writes `1`s to the memory. + + // Zeroize master components + master.depth.zeroize(); + { + let fingerprint: &mut [u8; 4] = master.parent_fingerprint.as_mut(); + fingerprint.zeroize(); + } + master.private_key.non_secure_erase(); + { + let chaincode: &mut [u8; 32] = master.chain_code.as_mut(); + chaincode.zeroize(); + } + let raw_ptr = &mut master.child_number as *mut ChildNumber; + // SAFETY: `master.child_number` is a valid enum variant + // and will not be accessed after zeroization. + // Also there are only two possible variants that will + // always have an `index` which is a `u32`. + // Note that `ChildNumber` does not have the `#[non_exhaustive]` + // attribute. + unsafe { + *raw_ptr = if master.child_number.is_normal() { + ChildNumber::Normal { index: 0 } + } else { + ChildNumber::Hardened { index: 0 } + }; + } + + // Zeroize derived components + derived.depth.zeroize(); + { + let fingerprint: &mut [u8; 4] = derived.parent_fingerprint.as_mut(); + fingerprint.zeroize(); + } + derived.private_key.non_secure_erase(); + { + let chaincode: &mut [u8; 32] = derived.chain_code.as_mut(); + chaincode.zeroize(); + } + let raw_ptr = &mut derived.child_number as *mut ChildNumber; + // SAFETY: `derived.child_number` is a valid enum variant + // and will not be accessed after zeroization. + // Also there are only two possible variants that will + // always have an `index` which is a `u32`. + // Note that `ChildNumber` does not have the `#[non_exhaustive]` + // attribute. + unsafe { + *raw_ptr = if derived.child_number.is_normal() { + ChildNumber::Normal { index: 0 } + } else { + ChildNumber::Hardened { index: 0 } + }; + } + } +} + +#[cfg(feature = "zeroize")] +impl ZeroizeOnDrop for SequencerKeys {} + +#[cfg(test)] +mod tests { + + use bitcoin::Network; + + use super::*; + + #[test] + #[cfg(feature = "zeroize")] + fn test_zeroize() { + let master = Xpriv::new_master(Network::Regtest, &[2u8; 32]).unwrap(); + let mut keys = SequencerKeys::new(&master).unwrap(); + + // Store original values + let master_chaincode = *keys.master_xpriv().chain_code.as_bytes(); + let derived_chaincode = *keys.derived_xpriv().chain_code.as_bytes(); + + // Verify data exists + assert_ne!(master_chaincode, [0u8; 32]); + assert_ne!(derived_chaincode, [0u8; 32]); + + // Manually zeroize + keys.zeroize(); + + // Verify fields are zeroed + // NOTE: SecretKey::non_secure_erase writes `1`s to the memory. + assert_eq!(keys.master_xpriv().private_key.secret_bytes(), [1u8; 32]); + assert_eq!(keys.derived_xpriv().private_key.secret_bytes(), [1u8; 32]); + assert_eq!(*keys.master_xpriv().chain_code.as_bytes(), [0u8; 32]); + assert_eq!(*keys.derived_xpriv().chain_code.as_bytes(), [0u8; 32]); + assert_eq!(*keys.master_xpriv().parent_fingerprint.as_bytes(), [0u8; 4]); + assert_eq!( + *keys.derived_xpriv().parent_fingerprint.as_bytes(), + [0u8; 4] + ); + assert_eq!(keys.master_xpriv().depth, 0); + assert_eq!(keys.derived_xpriv().depth, 0); + + // Check if child numbers are zeroed while maintaining their hardened/normal status + match keys.master_xpriv().child_number { + ChildNumber::Normal { index } => assert_eq!(index, 0), + ChildNumber::Hardened { index } => assert_eq!(index, 0), + } + match keys.derived_xpriv().child_number { + ChildNumber::Normal { index } => assert_eq!(index, 0), + ChildNumber::Hardened { index } => assert_eq!(index, 0), + } + } +} diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 058ee7bd7..97e432c58 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -20,11 +20,13 @@ serde_json.workspace = true sha2.workspace = true thiserror.workspace = true tracing.workspace = true +zeroize = { workspace = true, optional = true } [dev-dependencies] strata-test-utils.workspace = true [features] -default = ["std", "rand"] +default = ["std", "rand", "zeroize"] rand = ["std", "dep:rand"] std = ["dep:secp256k1"] +zeroize = ["std", "dep:zeroize"] diff --git a/crates/primitives/src/buf.rs b/crates/primitives/src/buf.rs index f3a7b8d18..f7217f16c 100644 --- a/crates/primitives/src/buf.rs +++ b/crates/primitives/src/buf.rs @@ -6,17 +6,64 @@ use bitcoin::{ BlockHash, Txid, }; use reth_primitives::revm_primitives::alloy_primitives::hex; +#[cfg(feature = "zeroize")] +use zeroize::Zeroize; use crate::{errors::ParseError, macros::internal}; -// 20-byte buf +/// A 20-byte buffer. +/// +/// # Warning +/// +/// This type is not zeroized on drop. +/// However, it implements the [`Zeroize`] trait, so you can zeroize it manually. +/// This is useful for secret data that needs to be zeroized after use. +/// +/// # Example +/// +/// ``` +/// # use strata_primitives::prelude::Buf20; +/// use zeroize::Zeroize; +/// +/// let mut buf = Buf20::from([1; 20]); +/// buf.zeroize(); +/// +/// assert_eq!(buf, Buf20::from([0; 20])); +/// ``` #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Buf20(pub [u8; 20]); internal::impl_buf_common!(Buf20, 20); internal::impl_buf_serde!(Buf20, 20); -// 32-byte buf, useful for hashes and schnorr pubkeys +// NOTE: we cannot do `ZeroizeOnDrop` since `Buf20` is `Copy`. +impl Zeroize for Buf20 { + #[inline] + fn zeroize(&mut self) { + self.0.zeroize(); + } +} +/// A 32-byte buffer. +/// +/// This is useful for hashes, transaction IDs, secret and public keys. +/// +/// # Warning +/// +/// This type is not zeroized on drop. +/// However, it implements the [`Zeroize`] trait, so you can zeroize it manually. +/// This is useful for secret data that needs to be zeroized after use. +/// +/// # Example +/// +/// ``` +/// # use strata_primitives::prelude::Buf32; +/// use zeroize::Zeroize; +/// +/// let mut buf = Buf32::from([1; 32]); +/// buf.zeroize(); +/// +/// assert_eq!(buf, Buf32::from([0; 32])); +/// ``` #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Buf32(pub [u8; 32]); internal::impl_buf_common!(Buf32, 32); @@ -78,7 +125,36 @@ impl From for Buf32 { } } -// 64-byte buf, useful for schnorr signatures +// NOTE: we cannot do `ZeroizeOnDrop` since `Buf32` is `Copy`. +#[cfg(feature = "zeroize")] +impl Zeroize for Buf32 { + #[inline] + fn zeroize(&mut self) { + self.0.zeroize(); + } +} + +/// A 64-byte buffer. +/// +/// This is useful for schnorr signatures. +/// +/// # Warning +/// +/// This type is not zeroized on drop. +/// However, it implements the [`Zeroize`] trait, so you can zeroize it manually. +/// This is useful for secret data that needs to be zeroized after use. +/// +/// # Example +/// +/// ``` +/// # use strata_primitives::prelude::Buf64; +/// use zeroize::Zeroize; +/// +/// let mut buf = Buf64::from([1; 64]); +/// buf.zeroize(); +/// +/// assert_eq!(buf, Buf64::from([0; 64])); +/// ``` #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Buf64(pub [u8; 64]); internal::impl_buf_common!(Buf64, 64); @@ -90,9 +166,18 @@ impl From for Buf64 { } } +// NOTE: we cannot do `ZeroizeOnDrop` since `Buf64` is `Copy`. +#[cfg(feature = "zeroize")] +impl Zeroize for Buf64 { + #[inline] + fn zeroize(&mut self) { + self.0.zeroize(); + } +} + #[cfg(test)] mod tests { - use super::Buf32; + use super::*; #[test] fn test_buf32_deserialization() { @@ -143,4 +228,18 @@ mod tests { String::from("\"0x01010101010101010101010101010101010101010101010101010101010101aa\"") ); } + + #[test] + #[cfg(feature = "zeroize")] + fn test_zeroize() { + let mut buf20 = Buf20::from([1; 20]); + let mut buf32 = Buf32::from([1; 32]); + let mut buf64 = Buf64::from([1; 64]); + buf20.zeroize(); + buf32.zeroize(); + buf64.zeroize(); + assert_eq!(buf20, Buf20::from([0; 20])); + assert_eq!(buf32, Buf32::from([0; 32])); + assert_eq!(buf64, Buf64::from([0; 64])); + } } diff --git a/crates/primitives/src/constants.rs b/crates/primitives/src/constants.rs index 53f6e9f4d..96b02794f 100644 --- a/crates/primitives/src/constants.rs +++ b/crates/primitives/src/constants.rs @@ -2,7 +2,10 @@ use std::sync::LazyLock; -use bitcoin::XOnlyPublicKey; +use bitcoin::{ + bip32::{ChildNumber, DerivationPath}, + XOnlyPublicKey, +}; use secp256k1::hashes::{sha256, Hash}; /// The size (in bytes) of a [`musig2::PartialSignature`]. @@ -20,6 +23,73 @@ pub const SEC_NONCE_SIZE: usize = 64; /// The size (in bytes) of a Hash (such as [`Txid`](bitcoin::Txid)). pub const HASH_SIZE: usize = 32; +/// Strata base index for keys. +/// +/// NOTE: These should be _hardened_. +pub const STRATA_BASE_IDX: u32 = 56; + +/// Strata sequencer index for keys. +/// +/// NOTE: These should be _hardened_. +pub const STRATA_SEQUENCER_IDX: u32 = 20; + +/// Strata operator index for keys. +/// +/// NOTE: These should be _hardened_. +pub const STRATA_OPERATOR_IDX: u32 = 20; + +/// Strata message index for the operator message key. +/// +/// NOTE: These should be _normal_. +pub const STRATA_OPERATOR_MESSAGE_IDX: u32 = 100; + +/// Strata Wallet index for the operator wallet key. +/// +/// NOTE: These should be _normal_. +pub const STRATA_OPERATOR_WALLET_IDX: u32 = 101; + +/// Strata [`DerivationPath`] for sequencer's key. +/// +/// This corresponds to the path: `m/56'/10'`. +pub static STRATA_SEQUENCER_DERIVATION_PATH: LazyLock = LazyLock::new(|| { + DerivationPath::master().extend([ + ChildNumber::from_hardened_idx(STRATA_BASE_IDX).expect("valid hardened child number"), + ChildNumber::from_hardened_idx(STRATA_SEQUENCER_IDX).expect("valid hardened child number"), + ]) +}); + +/// Strata base [`DerivationPath`] for operator's message key. +/// +/// This corresponds to the path: `m/56'/20'`. +pub static STRATA_OPERATOR_BASE_DERIVATION_PATH: LazyLock = LazyLock::new(|| { + DerivationPath::master().extend([ + ChildNumber::from_hardened_idx(STRATA_BASE_IDX).expect("valid hardened child number"), + ChildNumber::from_hardened_idx(STRATA_OPERATOR_IDX).expect("valid hardened child number"), + ]) +}); + +/// Strata [`DerivationPath`] for operator's key. +/// +/// This corresponds to the path: `m/56'/20'/101`. +pub static STRATA_OP_MESSAGE_DERIVATION_PATH: LazyLock = LazyLock::new(|| { + DerivationPath::master().extend([ + ChildNumber::from_hardened_idx(STRATA_BASE_IDX).expect("valid hardened child number"), + ChildNumber::from_hardened_idx(STRATA_OPERATOR_IDX).expect("valid hardened child number"), + ChildNumber::from_normal_idx(STRATA_OPERATOR_MESSAGE_IDX) + .expect("valid hardened child number"), + ]) +}); +/// Strata [`DerivationPath`] for operator's wallet key. +/// +/// This corresponds to the path: `m/56'/20'/101`. +pub static STRATA_OP_WALLET_DERIVATION_PATH: LazyLock = LazyLock::new(|| { + DerivationPath::master().extend([ + ChildNumber::from_hardened_idx(STRATA_BASE_IDX).expect("valid hardened child number"), + ChildNumber::from_hardened_idx(STRATA_OPERATOR_IDX).expect("valid hardened child number"), + ChildNumber::from_normal_idx(STRATA_OPERATOR_WALLET_IDX) + .expect("valid hardened child number"), + ]) +}); /// A verifiably unspendable public key, produced by hashing a fixed string to a curve group /// generator. /// @@ -37,7 +107,31 @@ pub static UNSPENDABLE_PUBLIC_KEY: LazyLock = LazyLock::new(|| { #[cfg(test)] mod tests { - use super::UNSPENDABLE_PUBLIC_KEY; + use super::*; + + #[test] + fn test_sequencer_path() { + // Check that construction of the sequencer derivation path succeeds + let _ = *STRATA_SEQUENCER_DERIVATION_PATH; + } + + #[test] + fn test_operator_base_path() { + // Check that construction of the operator base derivation path succeeds + let _ = *STRATA_OPERATOR_BASE_DERIVATION_PATH; + } + + #[test] + fn test_operator_message_path() { + // Check that construction of the operator message derivation path succeeds + let _ = *STRATA_OP_MESSAGE_DERIVATION_PATH; + } + + #[test] + fn test_operator_wallet_path() { + // Check that construction of the operator wallet derivation path succeeds + let _ = *STRATA_OP_WALLET_DERIVATION_PATH; + } #[test] fn test_unspendable() { diff --git a/crates/primitives/src/keys.rs b/crates/primitives/src/keys.rs new file mode 100644 index 000000000..59c00d412 --- /dev/null +++ b/crates/primitives/src/keys.rs @@ -0,0 +1,129 @@ +//! Key types used in the Strata library. +//! +//! [`Zeroize`] and [`Zeroize`] on drop should always ensure that keys are zeroized. + +use std::ops::Deref; + +use bitcoin::bip32::Xpriv; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// A zeroizable on [`Drop`] wrapper around [`Xpriv`]. +#[cfg(feature = "zeroize")] +#[derive(Clone, PartialEq, Eq)] +pub struct ZeroizableXpriv(Xpriv); + +impl ZeroizableXpriv { + /// Create a new [`ZeroizableXpriv`] from an [`Xpriv`]. + /// + /// This should take ownership of the `xpriv` since it is zeroized on drop. + pub fn new(xpriv: Xpriv) -> Self { + Self(xpriv) + } +} + +impl From for ZeroizableXpriv { + fn from(xpriv: Xpriv) -> Self { + Self::new(xpriv) + } +} + +impl Deref for ZeroizableXpriv { + type Target = Xpriv; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// Manual Drop implementation to zeroize keys on drop. +impl Drop for ZeroizableXpriv { + fn drop(&mut self) { + #[cfg(feature = "zeroize")] + self.zeroize(); + } +} + +#[cfg(feature = "zeroize")] +impl Zeroize for ZeroizableXpriv { + #[inline] + fn zeroize(&mut self) { + self.0.private_key.non_secure_erase(); + } +} + +#[cfg(feature = "zeroize")] +impl ZeroizeOnDrop for ZeroizableXpriv {} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + + use super::*; + + // What's better than bacon? bacon^24 of course + // Thix xpriv was generated by the bacon^24 mnemonic + // Don't use this in production! + const XPRIV_STR: &str = "tprv8ZgxMBicQKsPeh9dSitM82FU7Fz3ZgPkKmmovAr2aqwauAMVgjcEkZBb2etBtRPZ8XYVm7shxcKwVaDus7T5kauJXVsqAfzM4Tty13rRjAG"; + + #[test] + fn test_deref() { + let xpriv = XPRIV_STR.parse::().unwrap(); + let zeroizable_xpriv = ZeroizableXpriv::new(xpriv); + + assert_eq!(*zeroizable_xpriv, xpriv); + } + + #[test] + #[cfg(feature = "zeroize")] + fn test_zeroizable_xpriv() { + let xpriv = XPRIV_STR.parse::().unwrap(); + let mut zeroizable_xpriv = ZeroizableXpriv::new(xpriv); + + // Manually zeroize the key + zeroizable_xpriv.zeroize(); + + // Check that the key is zeroized + // NOTE: SecretKey::non_secure_erase writes `1`s to the memory. + assert_eq!(zeroizable_xpriv.private_key.secret_bytes(), [1u8; 32]); + } + + #[test] + #[cfg(feature = "zeroize")] + fn test_zeroize_on_drop_xpriv() { + // Create an atomic flag to track if zeroize was called + let was_zeroized = Arc::new(AtomicBool::new(false)); + let was_zeroized_clone = Arc::clone(&was_zeroized); + + // Create a wrapper struct that will set a flag when dropped + struct TestWrapper { + inner: ZeroizableXpriv, + flag: Arc, + } + + impl Drop for TestWrapper { + fn drop(&mut self) { + // Get the current value before the inner value is dropped + let bytes = self.inner.private_key.secret_bytes(); + // The inner ZeroizableXpriv will be dropped after this, + // triggering zeroization + // NOTE: SecretKey::non_secure_erase writes `1`s to the memory. + self.flag.store(bytes != [1u8; 32], Ordering::Relaxed); + } + } + + // Create and drop our test wrapper + { + let xpriv = XPRIV_STR.parse::().unwrap(); + let _ = TestWrapper { + inner: ZeroizableXpriv::new(xpriv), + flag: was_zeroized_clone, + }; + } + + // Check if zeroization occurred + assert!(was_zeroized.load(Ordering::Relaxed)); + } +} diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index a3d397e9d..5daa31bfb 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -14,6 +14,7 @@ pub mod l1; pub mod l2; #[macro_use] mod macros; +pub mod keys; pub mod operator; pub mod params; pub mod prelude; diff --git a/docker/init-keys.sh b/docker/init-keys.sh index 5b0aadc1d..058659c29 100755 --- a/docker/init-keys.sh +++ b/docker/init-keys.sh @@ -35,12 +35,12 @@ OP3_SEED_FILE=$CONFIG_FILE/operator3.bin OP4_SEED_FILE=$CONFIG_FILE/operator4.bin OP5_SEED_FILE=$CONFIG_FILE/operator5.bin -$DATATOOL_PATH -b regtest genseed -f $SEQ_SEED_FILE -$DATATOOL_PATH -b regtest genseed -f $OP1_SEED_FILE -$DATATOOL_PATH -b regtest genseed -f $OP2_SEED_FILE -$DATATOOL_PATH -b regtest genseed -f $OP3_SEED_FILE -$DATATOOL_PATH -b regtest genseed -f $OP4_SEED_FILE -$DATATOOL_PATH -b regtest genseed -f $OP5_SEED_FILE +$DATATOOL_PATH -b regtest genxpriv -f $SEQ_SEED_FILE +$DATATOOL_PATH -b regtest genxpriv -f $OP1_SEED_FILE +$DATATOOL_PATH -b regtest genxpriv -f $OP2_SEED_FILE +$DATATOOL_PATH -b regtest genxpriv -f $OP3_SEED_FILE +$DATATOOL_PATH -b regtest genxpriv -f $OP4_SEED_FILE +$DATATOOL_PATH -b regtest genxpriv -f $OP5_SEED_FILE seqkey=$($DATATOOL_PATH -b regtest genseqpubkey -f ${SEQ_SEED_FILE}) op1pubkey=$($DATATOOL_PATH -b regtest genopxpub -f ${OP1_SEED_FILE}) diff --git a/functional-tests/factory.py b/functional-tests/factory.py index f4b464ac9..35721001a 100644 --- a/functional-tests/factory.py +++ b/functional-tests/factory.py @@ -308,7 +308,7 @@ def next_idx(self) -> int: @flexitest.with_ectx("ctx") def create_operator( self, - root_xpriv: str, + master_xpriv: str, node_url: str, bitcoind_config: dict, ctx: flexitest.EnvContext, @@ -326,7 +326,7 @@ def create_operator( "strata-bridge-client", "operator", "--datadir", datadir, - "--root-xpriv", root_xpriv, + "--master-xpriv", master_xpriv, "--rpc-host", rpc_host, "--rpc-port", str(rpc_port), "--btc-url", "http://" + bitcoind_config["bitcoind_sock"], @@ -341,7 +341,7 @@ def create_operator( # TODO remove this after adding a proper config file # ruff: noqa: F841 envvars = { - "STRATA_OP_XPRIV": root_xpriv, + "STRATA_OP_MASTER_XPRIV": master_xpriv, } props = {"id": idx, "rpc_host": rpc_host, "rpc_port": rpc_port} diff --git a/functional-tests/utils.py b/functional-tests/utils.py index afc2722b1..5e6d2cd6f 100644 --- a/functional-tests/utils.py +++ b/functional-tests/utils.py @@ -286,7 +286,7 @@ def generate_seed_at(path: str): cmd = [ "strata-datatool", "-b", "regtest", - "genseed", + "genxpriv", "-f", path ] # fmt: on