diff --git a/Cargo.lock b/Cargo.lock index fb2d1e2d..69cc034c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,7 +78,23 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" dependencies = [ - "hex-conservative", + "hex-conservative 0.3.2", + "serde", +] + +[[package]] +name = "bitcoin-primitives" +version = "0.102.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48379e4d9a4456c038d78551ccbb8a3d7f9514c799f51ae815a6e947d64c9efe" +dependencies = [ + "bitcoin-consensus-encoding 0.1.0", + "bitcoin-internals", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative 0.3.2", + "hex-conservative 1.2.0", + "serde", ] [[package]] @@ -100,7 +116,8 @@ checksum = "9a8a45c2b41c457a9a9e4670422fcbdf109afb3b22bc920b4045e8bdfd788a3d" dependencies = [ "bitcoin-consensus-encoding 0.1.0", "bitcoin-internals", - "hex-conservative", + "hex-conservative 0.3.2", + "serde", ] [[package]] @@ -361,7 +378,7 @@ dependencies = [ "dash-pow", "dash-primitives", "dash-types", - "hex-conservative", + "hex-conservative 0.3.2", "indicatif", "json5", "proc-macro2", @@ -400,7 +417,7 @@ dependencies = [ "dash-primitives", "dash-script", "dash-types", - "hex-conservative", + "hex-conservative 0.3.2", "rstest", "serde", ] @@ -410,10 +427,12 @@ name = "dash-params" version = "0.0.0" dependencies = [ "bitcoin-consensus-encoding 0.2.0", + "bitcoin-primitives", "bitcoin-units", "dash-num", "dash-pow", "dash-primitives", + "dash-script", "dash-types", "hex-literal", "rstest", @@ -423,6 +442,7 @@ dependencies = [ name = "dash-pkc" version = "0.0.0" dependencies = [ + "base58ck", "bitcoin-consensus-encoding 0.2.0", "bitcoin_hashes", "blst", @@ -431,7 +451,7 @@ dependencies = [ "dash-num", "dash-types", "divan", - "hex-conservative", + "hex-conservative 0.3.2", "hex-literal", "k256", "rand_core", @@ -460,9 +480,9 @@ dependencies = [ name = "dash-primitives" version = "0.0.0" dependencies = [ - "base58ck", "bitcoin-consensus-encoding 0.2.0", "bitcoin-internals", + "bitcoin-primitives", "bitcoin-units", "bitcoin_hashes", "cfg-if", @@ -472,7 +492,7 @@ dependencies = [ "dash-pow", "dash-script", "dash-types", - "hex-conservative", + "hex-conservative 0.3.2", "hex-literal", "libm", "rstest", @@ -486,6 +506,7 @@ dependencies = [ "base58ck", "bitcoin-consensus-encoding 0.2.0", "bitcoin_hashes", + "dash-pkc", "dash-types", "hex-literal", "rstest", @@ -496,10 +517,13 @@ dependencies = [ name = "dash-types" version = "0.0.0" dependencies = [ + "base58ck", "bitcoin-consensus-encoding 0.2.0", + "bitcoin-primitives", + "bitcoin_hashes", "cfg-if", "dash-types-marker", - "hex-conservative", + "hex-conservative 0.3.2", "rstest", "serde", "zeroize", @@ -806,6 +830,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "0.4.1" diff --git a/contrib/codeql/lib/imports.qll b/contrib/codeql/lib/imports.qll index acb51bc7..f56b16e0 100644 --- a/contrib/codeql/lib/imports.qll +++ b/contrib/codeql/lib/imports.qll @@ -65,13 +65,18 @@ predicate isMacroReexport(Use u) { ) } -/** - * Holds if `u` is an allowlisted re-export of a marker subcrate - * through its owning crate (e.g. `dash-types-marker` via `dash-types`). - */ +/** Holds if `u` is an allowlisted re-export from a foreign crate. */ private predicate isAllowlistedReexport(Use u) { usePrefix(u) = "dash_types_marker" and fileOf(u).getAbsolutePath().matches("%pkgs/types/%") + or + usePrefix(u) = "dash_pkc" and + u.getUseTree().getPath().getSegment().getIdentifier().getText() = "__PubKeyHash" and + fileOf(u).getAbsolutePath().matches("%pkgs/script/%") + or + usePrefix(u) = "dash_types" and + u.getUseTree().getPath().getSegment().getIdentifier().getText() = "__ScriptHash" and + fileOf(u).getAbsolutePath().matches("%pkgs/script/%") } /** diff --git a/contrib/codeql/lib/policy.qll b/contrib/codeql/lib/policy.qll index a2782b1a..31996d39 100644 --- a/contrib/codeql/lib/policy.qll +++ b/contrib/codeql/lib/policy.qll @@ -54,7 +54,9 @@ predicate isSecretType(TypeItem t) { // A share *of a signature* is published, so it holds nothing to protect. Excluded by // exact name because `SecretKeyShare` and `RawShare` match the same Share substring // and do carry secret scalars. - not t.getName().getText() = "SignatureShare" + not t.getName().getText() = "SignatureShare" and + // Serde artifact to deserialize a tagged enum. + not t.getName().getText() = "__Seed" } /** diff --git a/contrib/codeql/zeroize.ql b/contrib/codeql/zeroize.ql index 84af8f23..e086e629 100644 --- a/contrib/codeql/zeroize.ql +++ b/contrib/codeql/zeroize.ql @@ -213,12 +213,49 @@ predicate callsCtEq(Function f) { } /** - * Holds if `f` decides something by a comparison that stops early, described - * by `how`. + * Holds if `e` reads byte storage rather than an opaque value. * - * The short-circuiting adapters walk only as far as the first byte that settles - * the answer, and `==` on a byte container lowers to `memcmp`, which does the - * same. + * Fields are judged by their declared type, resolved through the type layer, so + * a flag sitting beside the bytes is not mistaken for them. References and + * derefs are looked through. + */ +predicate bytesExpr(Expr e) { + e instanceof ArrayExpr + or + e.(MethodCallExpr).getIdentifier().getText() = + ["as_bytes", "as_ref", "as_slice", "to_bytes", "into_bytes", "as_array", "expose_secret"] + or + fieldMayHoldSecret(e.(FieldExpr).getStructField().getTypeRepr()) + or + fieldMayHoldSecret(e.(FieldExpr).getTupleField().getTypeRepr()) + or + bytesExpr(e.(RefExpr).getExpr()) + or + bytesExpr(e.(PrefixExpr).getExpr()) +} + +/** + * Holds if `f` compares byte storage with `how`, an operator that stops early. + * + * `==` on bytes compiles to `memcmp`, which short-circuits on the first + * mismatch. The operand gate keeps the rule on byte storage, so deciding on + * a flag, a length, or an enum discriminant beside the secret is not + * reported. Secrecy itself is not judged here: `variableTimeSecretTest` + * supplies that through `enforcedSecretType`. + */ +predicate comparesBytes(Function f, string how) { + exists(BinaryExpr be | + be.getEnclosingCallable() = f and + be.getOperatorName() = ["==", "!="] and + bytesExpr([be.getLhs(), be.getRhs()]) and + how = be.getOperatorName() + ) +} + +/** + * Holds if `f` decides something with a short-circuiting adapter, named `how`. + * + * These walk only as far as the first byte that settles the answer. */ predicate stopsEarly(Function f, string how) { exists(MethodCallExpr mc, string name | @@ -227,12 +264,6 @@ predicate stopsEarly(Function f, string how) { name = ["all", "any", "position", "find", "contains", "starts_with", "ends_with"] and how = name + "()" ) - or - exists(BinaryExpr be | - be.getEnclosingCallable() = f and - be.getOperatorName() = ["==", "!="] and - how = be.getOperatorName() - ) } /** @@ -251,7 +282,11 @@ predicate variableTimeSecretTest(Function f, string how) { not isTestCode(f) and not f.getName().getText() = "eq" and typeHead(f.getRetType().getTypeRepr()) = "bool" and - stopsEarly(f, how) and + ( + stopsEarly(f, how) + or + comparesBytes(f, how) + ) and not callsCtEq(f) ) } diff --git a/contrib/lint/lint_codeql.py b/contrib/lint/lint_codeql.py index 3e3294b6..1f15730c 100755 --- a/contrib/lint/lint_codeql.py +++ b/contrib/lint/lint_codeql.py @@ -37,7 +37,7 @@ ) _SOURCE_KEYWORDS = ( - "Serialize", "Deserialize", "Unencodable", "TypeId", "#[cfg", + "Serialize", "Deserialize", "Unencodable", "TypeId", "Zeroize", "#[cfg", ) diff --git a/contrib/samples/Cargo.lock b/contrib/samples/Cargo.lock index bad33b3f..1a3665c1 100644 --- a/contrib/samples/Cargo.lock +++ b/contrib/samples/Cargo.lock @@ -42,7 +42,23 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" dependencies = [ - "hex-conservative", + "hex-conservative 0.3.2", + "serde", +] + +[[package]] +name = "bitcoin-primitives" +version = "0.102.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48379e4d9a4456c038d78551ccbb8a3d7f9514c799f51ae815a6e947d64c9efe" +dependencies = [ + "bitcoin-consensus-encoding 0.1.0", + "bitcoin-internals", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative 0.3.2", + "hex-conservative 1.2.0", + "serde", ] [[package]] @@ -64,7 +80,8 @@ checksum = "9a8a45c2b41c457a9a9e4670422fcbdf109afb3b22bc920b4045e8bdfd788a3d" dependencies = [ "bitcoin-consensus-encoding 0.1.0", "bitcoin-internals", - "hex-conservative", + "hex-conservative 0.3.2", + "serde", ] [[package]] @@ -88,6 +105,24 @@ dependencies = [ "serde", ] +[[package]] +name = "dash-pkc" +version = "0.0.0" +dependencies = [ + "base58ck", + "bitcoin-consensus-encoding 0.2.0", + "bitcoin_hashes", + "cfg-if", + "dash-num", + "dash-types", + "hex-conservative 0.3.2", + "hex-literal", + "rand_core", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "dash-pow" version = "0.0.0" @@ -102,13 +137,16 @@ version = "0.0.0" dependencies = [ "bitcoin-consensus-encoding 0.2.0", "bitcoin-internals", + "bitcoin-primitives", "bitcoin-units", "bitcoin_hashes", "cfg-if", "dash-num", + "dash-pkc", + "dash-pow", "dash-script", "dash-types", - "hex-conservative", + "hex-conservative 0.3.2", "libm", "serde", ] @@ -119,7 +157,7 @@ version = "0.0.0" dependencies = [ "dash-primitives", "dash-types", - "hex-conservative", + "hex-conservative 0.3.2", "serde_json", "wasm-bindgen", ] @@ -129,12 +167,14 @@ name = "dash-sample-solver" version = "0.0.0" dependencies = [ "bitcoin-consensus-encoding 0.2.0", + "bitcoin-primitives", "bitcoin-units", "dash-num", "dash-pow", "dash-primitives", + "dash-script", "dash-types", - "hex-conservative", + "hex-conservative 0.3.2", "serde", "serde_json", "wasm-bindgen", @@ -147,6 +187,7 @@ dependencies = [ "base58ck", "bitcoin-consensus-encoding 0.2.0", "bitcoin_hashes", + "dash-pkc", "dash-types", "serde", ] @@ -155,9 +196,24 @@ dependencies = [ name = "dash-types" version = "0.0.0" dependencies = [ + "base58ck", "bitcoin-consensus-encoding 0.2.0", - "hex-conservative", + "bitcoin-primitives", + "bitcoin_hashes", + "cfg-if", + "dash-types-marker", + "hex-conservative 0.3.2", "serde", + "zeroize", +] + +[[package]] +name = "dash-types-marker" +version = "0.0.0" +dependencies = [ + "quote", + "syn", + "xxhash-rust", ] [[package]] @@ -169,6 +225,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "itoa" version = "1.0.18" @@ -211,6 +282,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rustversion" version = "1.0.22" @@ -260,6 +337,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -322,6 +405,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/contrib/samples/solver/Cargo.toml b/contrib/samples/solver/Cargo.toml index f07a3d8f..10053e69 100644 --- a/contrib/samples/solver/Cargo.toml +++ b/contrib/samples/solver/Cargo.toml @@ -22,12 +22,16 @@ full = ["std"] bitcoin-consensus-encoding = { version = "0.2", default-features = false, features = [ "alloc", ] } +bitcoin-primitives = { version = "0.102", default-features = false, features = [ + "alloc", +] } bitcoin-units = { version = "0.3", default-features = false, features = [ "alloc", ] } dash-num = { version = "0.0.0", path = "../../../pkgs/num", default-features = false } dash-pow = { version = "0.0.0", path = "../../../pkgs/pow", default-features = false, features = ["simd"] } dash-primitives = { version = "0.0.0", path = "../../../pkgs/primitives", default-features = false } +dash-script = { version = "0.0.0", path = "../../../pkgs/script", default-features = false } dash-types = { version = "0.0.0", path = "../../../pkgs/types", default-features = false } hex-conservative = { version = "0.3", default-features = false, features = ["alloc"] } serde = { version = "1", default-features = false, features = ["derive", "alloc"] } diff --git a/contrib/samples/solver/solver.rs b/contrib/samples/solver/solver.rs index c40e6bbe..cb5770cf 100644 --- a/contrib/samples/solver/solver.rs +++ b/contrib/samples/solver/solver.rs @@ -11,9 +11,10 @@ extern crate alloc; use bitcoin_consensus_encoding::encode_to_vec; +use bitcoin_primitives::script::{ScriptPubKeyBuf, ScriptSigBuf}; use bitcoin_units::Amount; use dash_num::{Arith256, CompactTarget}; -use dash_primitives::{BlockHash, BlockHeader, MerkleRoot, OutPoint, Script, Transaction, TxHash, TxIn, TxOut, TxType}; +use dash_primitives::{BlockHash, BlockHeader, MerkleRoot, OutPoint, Transaction, TxHash, TxIn, TxOut, TxType}; use dash_types::codec::Hashable; use hex_conservative::FromHex; use serde::{Deserialize, Serialize}; @@ -47,12 +48,12 @@ fn build_coinbase(script_sig: Vec, script_pubkey: Vec, amount_duffs: &st hash: TxHash::default(), index: 0xFFFF_FFFF, }, - script_sig: Script::new(script_sig), + script_sig: ScriptSigBuf::from_bytes(script_sig), sequence: 0xFFFF_FFFF, }], outputs: vec![TxOut { value, - script_pubkey: Script::new(script_pubkey), + script_pubkey: ScriptPubKeyBuf::from_bytes(script_pubkey), }], lock_time: 0, extra_payload: Vec::new(), diff --git a/contrib/semgrep/workspace.yml b/contrib/semgrep/workspace.yml index e4bebb58..86264ef3 100644 --- a/contrib/semgrep/workspace.yml +++ b/contrib/semgrep/workspace.yml @@ -1,20 +1,18 @@ rules: - id: attr-allow-discouraged - message: "allow attributions are discouraged except for dead_code and unused_imports" + message: "allow attributes are discouraged except for dead_code, unused_imports and unused_macros" severity: ERROR languages: [rust] paths: include: [/pkgs/**/*.rs, /contrib/samples/**/*.rs] - patterns: - - pattern-regex: |- - (?xm) - ^[ \t]* # line start, optional indent - \#!?\[allow\( # outer #[allow( or inner #![allow( - [^)]* \) # ... through the closing parenthesis - - pattern-not-regex: |- - (?x) - \#!?\[allow\( \s* # allow( plus optional whitespace - (?: dead_code | unused_imports ) \b # ... except these two + pattern-regex: |- + (?xm) + ^[ \t]* # line start, optional indent + \#!?\[allow\( # outer #[allow( or inner #![allow( + (?: " [^"]* " | [^")] )*? # any argument, stepping over reason text + \b (?! # ... that names neither the reason keyword + (?: reason | dead_code | unused_imports | unused_macros ) \b + ) [a-z] # ... nor a permitted lint - id: attr-inner-roots-only message: "inner attributes (#![...]) are only permitted in crate/module roots" diff --git a/pkgs/dev/src/bin/bsdk_util/bspcheck.rs b/pkgs/dev/src/bin/bsdk_util/bspcheck.rs index d21d336f..af5a7552 100644 --- a/pkgs/dev/src/bin/bsdk_util/bspcheck.rs +++ b/pkgs/dev/src/bin/bsdk_util/bspcheck.rs @@ -109,12 +109,12 @@ mod magic { use super::BootstrapError; use crate::Application; - use dash_params::types::{ChainParams, MessageStart}; + use dash_params::{ChainParams, MessageStart, Network}; const KNOWN_NETWORKS: &[&ChainParams] = &[ - &dash_params::main::PARAMS, - &dash_params::test3::PARAMS, - &dash_params::regtest::PARAMS, + Network::Main.chain(), + Network::Testnet3.chain(), + Network::Regtest.chain(), ]; fn detect(magic: MessageStart) -> Result<&'static ChainParams, BootstrapError> { @@ -438,7 +438,7 @@ fn verify_chunks( app: &Application, pool: &rayon::ThreadPool, reader: &mut BufReader>, - params: &dash_params::types::ChainParams, + params: &dash_params::ChainParams, genesis_data: Vec, budget_bytes: u64, report_secs: u64, diff --git a/pkgs/p2p_core/src/primitives/mn_list.rs b/pkgs/p2p_core/src/primitives/mn_list.rs index 2d820a61..8481b892 100644 --- a/pkgs/p2p_core/src/primitives/mn_list.rs +++ b/pkgs/p2p_core/src/primitives/mn_list.rs @@ -11,8 +11,9 @@ use crate::prelude::*; use dash_pkc::bls::{BlsPkBytes, BlsScIetf, BlsSigBytes}; use dash_primitives::{ - hash_impl, BlockHash, Commitment, KeyId, LlmqType, MnType, PlatformNodeId, ServiceV1, Transaction, TxHash, + hash_impl, BlockHash, Commitment, LlmqType, MnType, PlatformNodeId, ServiceV1, Transaction, TxHash, }; +use dash_script::PubKeyHash; use dash_types::codec::{BaseCodec, DecodeError, EncodeBuf, NumCodec}; use dash_types::TypeId; @@ -34,7 +35,7 @@ pub struct SimplifiedMnListEntry { /// BLS operator public key. pub operator_key: BlsPkBytes, /// Voting key hash (HASH160). - pub voting_key_id: KeyId, + pub voting_key_id: PubKeyHash, /// Whether this masternode is currently valid. pub is_valid: bool, /// Masternode type (Regular or Evo). @@ -55,7 +56,7 @@ impl BaseCodec for SimplifiedMnListEntry { let confirmed_hash = BlockHash::decode(data)?; let service = ServiceV1::decode(data)?; let operator_key = BlsPkBytes::::decode(data)?; - let voting_key_id = KeyId::decode(data)?; + let voting_key_id = PubKeyHash::decode(data)?; let is_valid = bool::decode(data)?; // nType is gated by the entry's version diff --git a/pkgs/params/Cargo.toml b/pkgs/params/Cargo.toml index 6258a46c..664ba4db 100644 --- a/pkgs/params/Cargo.toml +++ b/pkgs/params/Cargo.toml @@ -6,13 +6,15 @@ license = "MIT" [features] default = [] -std = ["dash-primitives/std"] +std = ["bitcoin-primitives/std", "dash-primitives/std", "dash-script/std"] full = ["std"] [dependencies] +bitcoin-primitives = { version = "0.102", default-features = false, features = ["alloc"] } bitcoin-units = { version = "0.3", default-features = false, features = ["alloc"] } dash-num = { version = "0.0.0", path = "../num" } dash-primitives = { version = "0.0.0", path = "../primitives" } +dash-script = { version = "0.0.0", path = "../script" } hex-literal = "0.4" [dev-dependencies] diff --git a/pkgs/params/src/lib.rs b/pkgs/params/src/lib.rs index a0936aa7..de698b39 100644 --- a/pkgs/params/src/lib.rs +++ b/pkgs/params/src/lib.rs @@ -12,11 +12,55 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; +mod mainnet; #[allow(unused_imports, reason = "ergonomic shim, exports may be unused")] mod prelude; +mod regtest; +mod test3; +mod types; -#[path = "mainnet.rs"] -pub mod main; -pub mod regtest; -pub mod test3; -pub mod types; +use dash_primitives::Block; +use dash_script::AddrParams; + +pub use types::*; + +/// Dash network identifier. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Network { + /// Production network. + Main, + /// Public testnet. + Testnet3, + /// Local regression test network. + Regtest, +} + +impl Network { + /// Full chain parameters for this network. + pub const fn chain(self) -> &'static ChainParams { + match self { + Self::Main => &mainnet::PARAMS, + Self::Testnet3 => &test3::PARAMS, + Self::Regtest => ®test::PARAMS, + } + } + + /// Address encoding parameters for this network. + pub const fn addr(self) -> &'static AddrParams { + &self.chain().addr_params + } + + /// Consensus parameters for this network. + pub const fn consensus(self) -> &'static ConsensusParams { + &self.chain().consensus + } + + /// Genesis block for this network. + pub fn genesis(self) -> Block { + match self { + Self::Main => mainnet::genesis(), + Self::Testnet3 => test3::genesis(), + Self::Regtest => regtest::genesis(), + } + } +} diff --git a/pkgs/params/src/mainnet.rs b/pkgs/params/src/mainnet.rs index d3bcd65d..404a7975 100644 --- a/pkgs/params/src/mainnet.rs +++ b/pkgs/params/src/mainnet.rs @@ -9,10 +9,10 @@ use crate::prelude::*; use crate::types::*; +use bitcoin_primitives::script::{ScriptPubKeyBuf, ScriptSigBuf}; use dash_num::{Arith256, Hash256}; -use dash_primitives::{ - Block, BlockHash, BlockHeader, MerkleRoot, OutPoint, Script, Transaction, TxHash, TxIn, TxOut, TxType, -}; +use dash_primitives::{Block, BlockHash, BlockHeader, MerkleRoot, OutPoint, Transaction, TxHash, TxIn, TxOut, TxType}; +use dash_script::AddrParams; use hex_literal::hex; /// Returns the mainnet genesis block. @@ -25,7 +25,7 @@ pub fn genesis() -> Block { hash: TxHash::default(), index: 0xFFFF_FFFF, }, - script_sig: Script::new( + script_sig: ScriptSigBuf::from_bytes( hex!( "04ffff001d01044c5957697265642030392f4a616e2f323031342054686520" "4772616e64204578706572696d656e7420476f6573204c6976653a204f7665" @@ -38,7 +38,7 @@ pub fn genesis() -> Block { }], outputs: vec![TxOut { value: bitcoin_units::Amount::from_btc_u16(50), - script_pubkey: Script::new( + script_pubkey: ScriptPubKeyBuf::from_bytes( hex!( "41040184710fa689ad5023690c80f3a49c8f13f8d45b8c857fbcbc8bc4a8e4" "d3eb4b10f4d4604fa08dce601aaf0f470216fe1b51850b4acf21b179c45070" @@ -66,7 +66,7 @@ pub fn genesis() -> Block { block } -pub const PARAMS: ChainParams = ChainParams { +pub static PARAMS: ChainParams = ChainParams { consensus: ConsensusParams { hash_genesis_block: Hash256::new(hex!("00000ffd590b1485b3caadc19b22e6379c733355108f107a430458cdf3407ab6")), subsidy_halving_interval: 210_240, @@ -162,14 +162,14 @@ pub const PARAMS: ChainParams = ChainParams { assumed_blockchain_size_gb: 57, assumed_chain_state_size_gb: 1, dns_seeds: &["dnsseed.dash.org."], - base58_prefixes: Base58Prefixes { - pubkey_address: 76, // addresses start with 'X' - script_address: 16, // addresses start with '7' - secret_key: 204, // keys start with '7' or 'X' - ext_public_key: [0x04, 0x88, 0xB2, 0x1E], // xpub - ext_secret_key: [0x04, 0x88, 0xAD, 0xE4], // xprv + addr_params: AddrParams { + pubkey_addr: 76, // addresses start with 'X' + script_addr: 16, // addresses start with '7' + secret_key: 204, // keys start with '7' or 'X' + ext_pubkey: [0x04, 0x88, 0xB2, 0x1E], // xpub + ext_secret: [0x04, 0x88, 0xAD, 0xE4], // xprv + bip44_idx: 5, }, - ext_coin_type: 5, // BIP44 coin type network_id: "main", is_test_chain: false, require_standard: true, diff --git a/pkgs/params/src/regtest.rs b/pkgs/params/src/regtest.rs index 15b88ab3..1b3771a5 100644 --- a/pkgs/params/src/regtest.rs +++ b/pkgs/params/src/regtest.rs @@ -9,10 +9,10 @@ use crate::prelude::*; use crate::types::*; +use bitcoin_primitives::script::{ScriptPubKeyBuf, ScriptSigBuf}; use dash_num::{Arith256, Hash256}; -use dash_primitives::{ - Block, BlockHash, BlockHeader, MerkleRoot, OutPoint, Script, Transaction, TxHash, TxIn, TxOut, TxType, -}; +use dash_primitives::{Block, BlockHash, BlockHeader, MerkleRoot, OutPoint, Transaction, TxHash, TxIn, TxOut, TxType}; +use dash_script::AddrParams; use hex_literal::hex; /// Returns the regtest genesis block. @@ -25,7 +25,7 @@ pub fn genesis() -> Block { hash: TxHash::default(), index: 0xFFFF_FFFF, }, - script_sig: Script::new( + script_sig: ScriptSigBuf::from_bytes( hex!( "04ffff001d01044c5957697265642030392f4a616e2f323031342054686520" "4772616e64204578706572696d656e7420476f6573204c6976653a204f7665" @@ -38,7 +38,7 @@ pub fn genesis() -> Block { }], outputs: vec![TxOut { value: bitcoin_units::Amount::from_btc_u16(50), - script_pubkey: Script::new( + script_pubkey: ScriptPubKeyBuf::from_bytes( hex!( "41040184710fa689ad5023690c80f3a49c8f13f8d45b8c857fbcbc8bc4a8e4" "d3eb4b10f4d4604fa08dce601aaf0f470216fe1b51850b4acf21b179c45070" @@ -66,7 +66,7 @@ pub fn genesis() -> Block { block } -pub const PARAMS: ChainParams = ChainParams { +pub static PARAMS: ChainParams = ChainParams { consensus: ConsensusParams { hash_genesis_block: Hash256::new(hex!("000008ca1832a4baf228eb1553c03d3a2c8e02399550dd6ea8d65cec3ef23d2e")), subsidy_halving_interval: 150, @@ -153,14 +153,14 @@ pub const PARAMS: ChainParams = ChainParams { assumed_blockchain_size_gb: 0, assumed_chain_state_size_gb: 0, dns_seeds: &[], - base58_prefixes: Base58Prefixes { - pubkey_address: 140, // addresses start with 'y' - script_address: 19, // addresses start with '8' or '9' - secret_key: 239, // keys start with '9' or 'c' - ext_public_key: [0x04, 0x35, 0x87, 0xCF], // tpub - ext_secret_key: [0x04, 0x35, 0x83, 0x94], // tprv + addr_params: AddrParams { + pubkey_addr: 140, // addresses start with 'y' + script_addr: 19, // addresses start with '8' or '9' + secret_key: 239, // keys start with '9' or 'c' + ext_pubkey: [0x04, 0x35, 0x87, 0xCF], // tpub + ext_secret: [0x04, 0x35, 0x83, 0x94], // tprv + bip44_idx: 1, }, - ext_coin_type: 1, // BIP44 testnet default network_id: "regtest", is_test_chain: true, require_standard: true, diff --git a/pkgs/params/src/test3.rs b/pkgs/params/src/test3.rs index 6c0eb5e3..29fc3433 100644 --- a/pkgs/params/src/test3.rs +++ b/pkgs/params/src/test3.rs @@ -9,10 +9,10 @@ use crate::prelude::*; use crate::types::*; +use bitcoin_primitives::script::{ScriptPubKeyBuf, ScriptSigBuf}; use dash_num::{Arith256, Hash256}; -use dash_primitives::{ - Block, BlockHash, BlockHeader, MerkleRoot, OutPoint, Script, Transaction, TxHash, TxIn, TxOut, TxType, -}; +use dash_primitives::{Block, BlockHash, BlockHeader, MerkleRoot, OutPoint, Transaction, TxHash, TxIn, TxOut, TxType}; +use dash_script::AddrParams; use hex_literal::hex; /// Returns the testnet genesis block. @@ -25,7 +25,7 @@ pub fn genesis() -> Block { hash: TxHash::default(), index: 0xFFFF_FFFF, }, - script_sig: Script::new( + script_sig: ScriptSigBuf::from_bytes( hex!( "04ffff001d01044c5957697265642030392f4a616e2f323031342054686520" "4772616e64204578706572696d656e7420476f6573204c6976653a204f7665" @@ -38,7 +38,7 @@ pub fn genesis() -> Block { }], outputs: vec![TxOut { value: bitcoin_units::Amount::from_btc_u16(50), - script_pubkey: Script::new( + script_pubkey: ScriptPubKeyBuf::from_bytes( hex!( "41040184710fa689ad5023690c80f3a49c8f13f8d45b8c857fbcbc8bc4a8e4" "d3eb4b10f4d4604fa08dce601aaf0f470216fe1b51850b4acf21b179c45070" @@ -66,7 +66,7 @@ pub fn genesis() -> Block { block } -pub const PARAMS: ChainParams = ChainParams { +pub static PARAMS: ChainParams = ChainParams { consensus: ConsensusParams { hash_genesis_block: Hash256::new(hex!("00000bafbc94add76cb75e2ec92894837288a481e5c005f6563d91623bf8bc2c")), subsidy_halving_interval: 210_240, @@ -159,14 +159,14 @@ pub const PARAMS: ChainParams = ChainParams { assumed_blockchain_size_gb: 10, assumed_chain_state_size_gb: 1, dns_seeds: &["testnet-seed.dashdot.io."], - base58_prefixes: Base58Prefixes { - pubkey_address: 140, // addresses start with 'y' - script_address: 19, // addresses start with '8' or '9' - secret_key: 239, // keys start with '9' or 'c' - ext_public_key: [0x04, 0x35, 0x87, 0xCF], // tpub - ext_secret_key: [0x04, 0x35, 0x83, 0x94], // tprv + addr_params: AddrParams { + pubkey_addr: 140, // addresses start with 'y' + script_addr: 19, // addresses start with '8' or '9' + secret_key: 239, // keys start with '9' or 'c' + ext_pubkey: [0x04, 0x35, 0x87, 0xCF], // tpub + ext_secret: [0x04, 0x35, 0x83, 0x94], // tprv + bip44_idx: 1, }, - ext_coin_type: 1, // BIP44 testnet default network_id: "test", is_test_chain: true, require_standard: false, diff --git a/pkgs/params/src/types.rs b/pkgs/params/src/types.rs index 381674c5..4d3e3e8c 100644 --- a/pkgs/params/src/types.rs +++ b/pkgs/params/src/types.rs @@ -8,6 +8,7 @@ pub(crate) use bitcoin_units::BlockHeight; use dash_num::{Arith256, Hash256}; +use dash_script::AddrParams; /// P2P network message start bytes (magic). pub type MessageStart = [u8; 4]; @@ -284,23 +285,6 @@ impl ConsensusParams { } } -/// Base58 address version prefixes. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct Base58Prefixes { - /// Version byte for pay-to-pubkey-hash addresses. - pub pubkey_address: u8, - /// Version byte for pay-to-script-hash addresses. - pub script_address: u8, - /// Version byte for WIF-encoded private keys. - pub secret_key: u8, - /// Four-byte prefix for BIP32 extended public - /// keys. - pub ext_public_key: [u8; 4], - /// Four-byte prefix for BIP32 extended secret - /// keys. - pub ext_secret_key: [u8; 4], -} - /// Complete chain parameters for a network. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ChainParams { @@ -326,10 +310,8 @@ pub struct ChainParams { pub assumed_chain_state_size_gb: u64, /// DNS seed hostnames for peer discovery. pub dns_seeds: &'static [&'static str], - /// Base58 address version prefixes. - pub base58_prefixes: Base58Prefixes, - /// BIP44 coin type for key derivation. - pub ext_coin_type: i32, + /// Address encoding parameters. + pub addr_params: AddrParams, /// Human-readable network identifier string. pub network_id: &'static str, /// Whether this is a test network. diff --git a/pkgs/params/tests/genesis_valid.rs b/pkgs/params/tests/genesis_valid.rs index d93c2d78..c99b31b3 100644 --- a/pkgs/params/tests/genesis_valid.rs +++ b/pkgs/params/tests/genesis_valid.rs @@ -6,7 +6,7 @@ //! Genesis validation test. -use dash_params::types::ChainParams; +use dash_params::{ChainParams, Network}; use dash_primitives::{Block, BlockHash, MerkleRoot}; use dash_types::codec::Hashable; use hex_literal::hex; @@ -14,18 +14,18 @@ use rstest::rstest; #[rstest] #[case::mainnet( - dash_params::main::genesis(), - &dash_params::main::PARAMS, + Network::Main.genesis(), + Network::Main.chain(), MerkleRoot::new(hex!("e0028eb9648db56b1ac77cf090b99048a8007e2bb64b68f092c03c7f56a662c7")), )] #[case::testnet( - dash_params::test3::genesis(), - &dash_params::test3::PARAMS, + Network::Testnet3.genesis(), + Network::Testnet3.chain(), MerkleRoot::new(hex!("e0028eb9648db56b1ac77cf090b99048a8007e2bb64b68f092c03c7f56a662c7")), )] #[case::regtest( - dash_params::regtest::genesis(), - &dash_params::regtest::PARAMS, + Network::Regtest.genesis(), + Network::Regtest.chain(), MerkleRoot::new(hex!("e0028eb9648db56b1ac77cf090b99048a8007e2bb64b68f092c03c7f56a662c7")), )] fn genesis_block_hash_matches( diff --git a/pkgs/pkc/Cargo.toml b/pkgs/pkc/Cargo.toml index d059ebcc..e1b51cb3 100644 --- a/pkgs/pkc/Cargo.toml +++ b/pkgs/pkc/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" license = "MIT" [dependencies] +base58ck = { version = "0.4", default-features = false, features = ["alloc"] } bitcoin-consensus-encoding = { version = "0.2", default-features = false, features = [ "alloc", ] } @@ -31,7 +32,10 @@ serde = { version = "1", default-features = false, features = [ ], optional = true } sha2 = { version = "0.10", default-features = false, optional = true } subtle = { version = "2", default-features = false } -zeroize = { version = "1", default-features = false, features = ["derive"] } +zeroize = { version = "1", default-features = false, features = [ + "alloc", + "derive", +] } [dev-dependencies] dash-dev = { version = "0.0.0", path = "../dev", features = ["full"] } @@ -45,12 +49,13 @@ serde = { version = "1", features = ["derive"] } default = [] std = [ "dep:rayon", + "base58ck/std", "bitcoin_hashes/std", "dash-types/std", "rand_core/getrandom", ] bls = ["dep:blst", "dep:sha2"] -ecdsa = ["dep:k256"] +ecdsa = ["dep:k256", "dep:hex-conservative"] serde = ["dep:serde", "dep:hex-conservative", "dash-num/serde", "dash-types/serde"] full = ["ecdsa", "bls", "serde", "std", "tests"] tests = ["std", "dep:hex-conservative", "dep:rstest"] diff --git a/pkgs/pkc/bench/ecdsa.rs b/pkgs/pkc/bench/ecdsa.rs index b42858df..4ea47e8c 100644 --- a/pkgs/pkc/bench/ecdsa.rs +++ b/pkgs/pkc/bench/ecdsa.rs @@ -7,10 +7,10 @@ //! Benchmarks for the ecdsa (secp256k1) feature use dash_pkc::ecdsa::tests::{message_hash, ALICE_SK}; -use dash_pkc::ecdsa::{EcdsaPublicKey, EcdsaSecretKey}; +use dash_pkc::ecdsa::{Compression, EcdsaPublicKey, EcdsaSecretKey}; fn test_key() -> EcdsaSecretKey { - EcdsaSecretKey::from_bytes(&ALICE_SK).unwrap() + EcdsaSecretKey::from_bytes(&ALICE_SK, Compression::Compressed).unwrap() } #[divan::bench] @@ -45,32 +45,32 @@ fn sign_recoverable(bencher: divan::Bencher) { fn recover(bencher: divan::Bencher) { let sk = test_key(); let msg = message_hash(55); - let (sig, rid) = sk.sign_recoverable(&msg).unwrap(); + let sig = sk.sign_recoverable(&msg).unwrap(); bencher .counter(divan::counter::ItemsCount::new(1u32)) - .bench(|| EcdsaPublicKey::recover(&msg, &sig, rid)); + .bench(|| EcdsaPublicKey::recover(&msg, &sig).unwrap()); } #[divan::bench] fn ser_pk(bencher: divan::Bencher) { let pk = test_key().public_key(); - bencher.bench(|| pk.to_bytes()); + bencher.bench(|| pk.to_compressed()); } #[divan::bench] fn deser_pk(bencher: divan::Bencher) { - let bytes = test_key().public_key().to_bytes(); - bencher.bench(|| EcdsaPublicKey::from_bytes(&bytes)); + let bytes = test_key().public_key().to_compressed(); + bencher.bench(|| EcdsaPublicKey::from_bytes(&bytes).unwrap()); } #[cfg(feature = "std")] mod worker_benches { use dash_pkc::ecdsa::tests::{message_hash, BOB_SK}; - use dash_pkc::ecdsa::{EcdsaPublicKey, EcdsaSecretKey, EcdsaSignature}; + use dash_pkc::ecdsa::{Compression, EcdsaPublicKey, EcdsaSecretKey, EcdsaSignature}; use dash_pkc::worker; fn setup_sigs(n: usize) -> Vec<(EcdsaSignature, EcdsaPublicKey, [u8; 32])> { - let sk = EcdsaSecretKey::from_bytes(&BOB_SK).unwrap(); + let sk = EcdsaSecretKey::from_bytes(&BOB_SK, Compression::Compressed).unwrap(); let pk = sk.public_key(); (0..n) .map(|i| { diff --git a/pkgs/pkc/src/ecdsa/error.rs b/pkgs/pkc/src/ecdsa/error.rs index 0cd1d4b1..4b2005c8 100644 --- a/pkgs/pkc/src/ecdsa/error.rs +++ b/pkgs/pkc/src/ecdsa/error.rs @@ -19,6 +19,8 @@ pub enum EcdsaError { InvalidSecretKey, /// signature bytes are malformed InvalidSignature, + /// DER-encoded private key has invalid structure + MalformedDer, /// recovery failed; no valid public key for this signature and message RecoveryFailed, /// signing operation failed @@ -42,6 +44,9 @@ impl fmt::Display for EcdsaError { Self::InvalidSignature => { write!(f, "signature bytes are malformed") } + Self::MalformedDer => { + write!(f, "DER-encoded private key has invalid structure") + } Self::RecoveryFailed => { write!(f, "recovery failed; no valid public key") } diff --git a/pkgs/pkc/src/ecdsa/mod.rs b/pkgs/pkc/src/ecdsa/mod.rs index c6489dfa..dff72daf 100644 --- a/pkgs/pkc/src/ecdsa/mod.rs +++ b/pkgs/pkc/src/ecdsa/mod.rs @@ -8,26 +8,62 @@ mod error; mod public_bytes; +mod public_hash; mod secret_bytes; mod sig_bytes; +mod sig_rec_bytes; + +use dash_types::Unencodable; pub use error::EcdsaError; -pub use public_bytes::EcdsaPkBytes; -pub use secret_bytes::EcdsaSkBytes; -pub use sig_bytes::EcdsaSigBytes; +pub use public_bytes::{EcdsaPkBytes, ECDSA_PK_LEN}; +pub use public_hash::PubKeyHash; +pub use secret_bytes::{EcdsaSkBytes, ECDSA_SK_LEN}; +pub use sig_bytes::{EcdsaSigBytes, ECDSA_SIG_LEN}; +pub use sig_rec_bytes::EcdsaRecSigBytes; + +/// Whether a key's public counterpart serializes in compressed (33-byte) or +/// uncompressed (65-byte) SEC1 form. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Unencodable)] +pub enum Compression { + /// The public key serializes compressed. + Compressed, + /// The public key serializes uncompressed. + Uncompressed, +} + +impl Compression { + /// Whether this is the compressed form. + pub const fn is_compressed(self) -> bool { + matches!(self, Self::Compressed) + } +} + +impl From for Compression { + fn from(compressed: bool) -> Self { + if compressed { + Self::Compressed + } else { + Self::Uncompressed + } + } +} cfg_if::cfg_if! { if #[cfg(feature = "ecdsa")] { mod public_ops; mod secret_ops; mod sig_ops; + mod sig_rec_ops; #[cfg(any(test, feature = "tests"))] #[expect(clippy::unwrap_used, reason = "test code")] + #[allow(dead_code, reason = "usage dependent on build flags")] pub mod tests; pub use public_ops::EcdsaPublicKey; pub use secret_ops::EcdsaSecretKey; - pub use sig_ops::{EcdsaDerSignature, EcdsaSignature, EcdsaRecoveryId}; + pub use sig_ops::{EcdsaDerSig, EcdsaSignature}; + pub use sig_rec_ops::EcdsaRecSignature; } } diff --git a/pkgs/pkc/src/ecdsa/public_bytes.rs b/pkgs/pkc/src/ecdsa/public_bytes.rs index 7db92252..e0334fcf 100644 --- a/pkgs/pkc/src/ecdsa/public_bytes.rs +++ b/pkgs/pkc/src/ecdsa/public_bytes.rs @@ -6,9 +6,260 @@ //! secp256k1 public key byte bag. -use dash_types::make_bytes; +use super::PubKeyHash; +use crate::prelude::*; -make_bytes! { - /// Raw compressed ECDSA public key bytes. - EcdsaPkBytes, 33 +use bitcoin_hashes::{ripemd160, sha256}; +use cfg_if::cfg_if; +use dash_types::codec::{ + read_bytes, read_compact_size, write_compact_size, BaseCodec, DecodeError, EncodeBuf, Hashable, +}; +use dash_types::TypeId; +use dash_types::{enum_map, impl_type}; + +use core::cmp::Ordering; +use core::fmt; + +/// Raw secp256k1 public key length without hints. +pub const ECDSA_PK_LEN: usize = 64; + +// secp256k1 compressed public key length with compression bit. +const ECDSA_PKCMP_LEN: usize = (ECDSA_PK_LEN / 2) + 1; + +enum_map! { + /// SEC1 public key header byte. + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + pub(super) enum Sec1Byte, u8 { + /// Compressed, even Y coordinate. + CompEven = 0x02, + /// Compressed, odd Y coordinate. + CompOdd = 0x03, + /// Uncompressed, no parity hint. + Uncomp = 0x04, + /// Hybrid, even Y hint (non-standard). + HybridEven = 0x06, + /// Hybrid, odd Y hint (non-standard). + HybridOdd = 0x07, + } +} + +impl Sec1Byte { + /// Whether this prefix indicates a compressed key. + pub const fn is_compressed(self) -> bool { + matches!(self, Self::CompEven | Self::CompOdd) + } + + /// Header-inclusive expected key length. + pub const fn size(self) -> usize { + match self { + Self::CompEven | Self::CompOdd => ECDSA_PKCMP_LEN, + Self::Uncomp | Self::HybridEven | Self::HybridOdd => ECDSA_PK_LEN + 1, + } + } +} + +/// SEC-1 encoded ECDSA public key bytes. +/// +/// The header byte is held as a parsed SEC1 prefix. The coordinates stay +/// unvalidated: only [`EcdsaPublicKey`](crate::ecdsa::EcdsaPublicKey) checks +/// curve membership. +#[derive(Clone, Copy, Eq, Hash, PartialEq, TypeId)] +pub struct EcdsaPkBytes { + prefix: Sec1Byte, + buf: [u8; ECDSA_PK_LEN + 1], +} + +impl BaseCodec for EcdsaPkBytes { + fn decode(data: &mut &[u8]) -> Result { + let n = read_compact_size(data, ECDSA_PK_LEN + 1)?; + let raw = read_bytes(data, n)?; + let prefix = raw + .first() + .and_then(|&b| Sec1Byte::from_base(b)) + .ok_or_else(|| DecodeError::InvalidValue { + expected: Sec1Byte::variants().iter().map(|p| u64::from(p.to_base())).collect(), + actual: raw.first().map_or(0, |&b| u64::from(b)), + })?; + if n != prefix.size() { + return Err(DecodeError::BadLen { + expected: vec![prefix.size()], + actual: n, + }); + } + Ok(Self::from_raw(prefix, raw)) + } + + fn encode(&self, buf: &mut impl EncodeBuf) { + let bytes = self.as_bytes(); + write_compact_size(bytes.len(), buf); + buf.extend_from_slice(bytes); // nosemgrep: codec-no-raw-extend + } +} + +impl_type!(EcdsaPkBytes); + +impl Hashable for EcdsaPkBytes { + type Hash = PubKeyHash; + + fn hash(&self) -> Self::Hash { + Self::Hash::from(*ripemd160::Hash::hash(sha256::Hash::hash(self.as_bytes()).as_ref()).as_byte_array()) + } +} + +impl EcdsaPkBytes { + /// Copies `prefix.size()` bytes without validating the coordinates. + pub(super) fn from_raw(prefix: Sec1Byte, bytes: &[u8]) -> Self { + debug_assert_eq!(bytes.len(), prefix.size(), "from_raw: length disagrees with prefix"); + let mut buf = [0xFFu8; ECDSA_PK_LEN + 1]; + let len = bytes.len().min(prefix.size()); + buf[..len].copy_from_slice(&bytes[..len]); + Self { prefix, buf } + } + + /// The raw SEC1 bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.buf[..self.size()] + } + + /// Returns `true` when the key is compressed. + pub fn is_compressed(&self) -> bool { + self.prefix.is_compressed() + } + + /// Constructs from raw SEC1 bytes. + pub fn from_bytes(bytes: &[u8]) -> Option { + let prefix = Sec1Byte::from_base(*bytes.first()?)?; + if bytes.len() != prefix.size() { + return None; + } + Some(Self::from_raw(prefix, bytes)) + } + + /// Active byte length. + pub fn size(&self) -> usize { + self.prefix.size() + } +} + +impl fmt::Debug for EcdsaPkBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "EcdsaPkBytes({self})") + } +} + +impl fmt::Display for EcdsaPkBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.as_bytes() { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl Ord for EcdsaPkBytes { + fn cmp(&self, other: &Self) -> Ordering { + self.as_bytes().cmp(other.as_bytes()) + } +} + +impl PartialOrd for EcdsaPkBytes { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +cfg_if! { + if #[cfg(feature = "serde")] { + use dash_types::serialize::hex as serde_hex; + use serde::de::Error; + use serde::{Deserializer, Serializer}; + + impl ::serde::Serialize for EcdsaPkBytes { + fn serialize(&self, serializer: S) -> Result { + serde_hex::serialize(self.as_bytes(), serializer) + } + } + + impl<'de> ::serde::Deserialize<'de> for EcdsaPkBytes { + fn deserialize>(deserializer: D) -> Result { + Self::from_bytes(&serde_hex::deserialize(deserializer)?).ok_or_else(|| D::Error::custom("invalid public key")) + } + } + } +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test code")] +mod tests { + use super::{EcdsaPkBytes, Sec1Byte, ECDSA_PKCMP_LEN, ECDSA_PK_LEN}; + use crate::prelude::*; + + use hex_conservative::hex; + use rstest::*; + + const COMPRESSED_02: [u8; ECDSA_PKCMP_LEN] = + hex!("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc"); + const COMPRESSED_03: [u8; ECDSA_PKCMP_LEN] = + hex!("0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"); + + #[rstest] + fn compressed_roundtrip() { + let pk = EcdsaPkBytes::from_bytes(&COMPRESSED_02).unwrap(); + assert!(pk.is_compressed()); + assert_eq!(pk.size(), ECDSA_PKCMP_LEN); + assert_eq!(pk.as_bytes(), &COMPRESSED_02); + } + + #[rstest] + fn display_is_hex() { + let pk = EcdsaPkBytes::from_bytes(&COMPRESSED_02).unwrap(); + let s = format!("{pk}"); + assert_eq!(s.len(), ECDSA_PKCMP_LEN * 2); + assert!(s.starts_with("02")); + } + + #[rstest] + #[case::comp_odd(0x03, true, Sec1Byte::CompOdd, ECDSA_PKCMP_LEN)] + #[case::hybrid_even(0x06, false, Sec1Byte::HybridEven, ECDSA_PK_LEN + 1)] + #[case::hybrid_odd(0x07, false, Sec1Byte::HybridOdd, ECDSA_PK_LEN + 1)] + fn from_bytes_prefix(#[case] prefix: u8, #[case] compressed: bool, #[case] expected: Sec1Byte, #[case] len: usize) { + let buf = [prefix; ECDSA_PK_LEN + 1]; + let pk = EcdsaPkBytes::from_bytes(&buf[..len]).unwrap(); + assert_eq!(pk.is_compressed(), compressed); + assert_eq!(pk.as_bytes()[0], expected.to_base()); + assert_eq!(pk.size(), expected.size()); + } + + #[rstest] + #[case::bad_prefix(&[0x05; ECDSA_PK_LEN / 2])] + #[case::wrong_length(&COMPRESSED_02[..32])] + #[case::truncated(&[0x02u8] as &[u8])] + fn from_bytes_rejects_invalid(#[case] input: &[u8]) { + assert!(EcdsaPkBytes::from_bytes(input).is_none()); + } + + #[rstest] + fn from_bytes_uncompressed() { + let mut buf = [0x04u8; ECDSA_PK_LEN + 1]; + buf[1..33].copy_from_slice(&COMPRESSED_02[1..]); + buf[33..].copy_from_slice(&[0xab; ECDSA_PK_LEN / 2]); + let pk = EcdsaPkBytes::from_bytes(&buf).unwrap(); + assert!(!pk.is_compressed()); + assert_eq!(pk.size(), ECDSA_PK_LEN + 1); + assert_eq!(pk.as_bytes(), &buf); + } + + #[rstest] + fn ordering_matches_bytes() { + let a = EcdsaPkBytes::from_bytes(&COMPRESSED_02).unwrap(); + let b = EcdsaPkBytes::from_bytes(&COMPRESSED_03).unwrap(); + assert_eq!(a.cmp(&b), a.as_bytes().cmp(b.as_bytes())); + } + + #[rstest] + #[case(0x00)] + #[case(0x05)] + fn sec1_byte_rejects_invalid(#[case] byte: u8) { + assert!(Sec1Byte::from_base(byte).is_none()); + } } diff --git a/pkgs/pkc/src/ecdsa/public_hash.rs b/pkgs/pkc/src/ecdsa/public_hash.rs new file mode 100644 index 00000000..d5da806a --- /dev/null +++ b/pkgs/pkc/src/ecdsa/public_hash.rs @@ -0,0 +1,28 @@ +// +// Copyright (c) 2026-present, The Dash Core developers +// SPDX-License-Identifier: MIT +// See the accompanying file LICENSE or https://opensource.org/license/MIT +// + +//! Hashed representation of secp256k1 public key. + +use crate::prelude::*; + +use base58ck::encode_check; +use dash_types::codec::{ArrayBuf, BaseCodec, EncodeBuf}; +use dash_types::make_bytes; + +make_bytes! { + /// 20-byte public key hash. + PubKeyHash, 20 +} + +impl PubKeyHash { + /// Encode as a Base58Check address with the given version prefix. + pub fn to_base58c(&self, prefix: u8) -> String { + let mut buf = ArrayBuf::<21>::new(); + buf.push(prefix); + self.encode(&mut buf); + encode_check(&buf.into_array()) + } +} diff --git a/pkgs/pkc/src/ecdsa/public_ops.rs b/pkgs/pkc/src/ecdsa/public_ops.rs index d8ce5b32..6c3cb329 100644 --- a/pkgs/pkc/src/ecdsa/public_ops.rs +++ b/pkgs/pkc/src/ecdsa/public_ops.rs @@ -7,104 +7,224 @@ //! secp256k1 public key. use super::error::EcdsaError; -use super::sig_ops::{EcdsaRecoveryId, EcdsaSignature}; -use super::EcdsaPkBytes; +use super::public_bytes::{EcdsaPkBytes, Sec1Byte, ECDSA_PK_LEN}; +use super::sig_ops::EcdsaSignature; +use super::sig_rec_ops::EcdsaRecSignature; +use super::{Compression, EcdsaRecSigBytes, PubKeyHash}; -use dash_types::{type_cvrt, Unencodable}; +use dash_types::{dlgt_codec, type_cvrt, TypeId, Unencodable}; use k256::ecdsa::{signature::hazmat::PrehashVerifier, VerifyingKey}; use core::hash::{Hash, Hasher}; +/// The SEC1 form a public key serializes back to. +/// +/// Retained separately from the curve point because the point alone cannot +/// distinguish the uncompressed and hybrid encodings, and re-emitting one as +/// the other would change the key's wire image and therefore its hash. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Unencodable)] +pub(super) enum PkForm { + /// 33-byte `0x02`/`0x03` form. + Compressed, + /// 65-byte `0x04` form. + Uncompressed, + /// 65-byte `0x06`/`0x07` form carrying a redundant parity hint. + Hybrid, +} + /// A secp256k1 public key. -#[derive(Clone, Debug, Eq, PartialEq, Unencodable)] +#[derive(Clone, Debug, Eq, PartialEq, TypeId)] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -#[cfg_attr( - feature = "serde", - serde(into = "super::EcdsaPkBytes", try_from = "super::EcdsaPkBytes",) -)] -pub struct EcdsaPublicKey(VerifyingKey); +#[cfg_attr(feature = "serde", serde(into = "EcdsaPkBytes", try_from = "EcdsaPkBytes"))] +pub struct EcdsaPublicKey { + inner: VerifyingKey, + form: PkForm, +} + +dlgt_codec!(EcdsaPublicKey => EcdsaPkBytes, PubKeyHash, EcdsaError, ECDSA_PK_LEN + 2); impl EcdsaPublicKey { - pub(super) fn from_inner(inner: VerifyingKey) -> Self { - Self(inner) + pub(super) fn from_inner(inner: VerifyingKey, compressed: Compression) -> Self { + Self { + inner, + form: match compressed { + Compression::Compressed => PkForm::Compressed, + Compression::Uncompressed => PkForm::Uncompressed, + }, + } + } + + /// Borrow the inner verifying key. + pub(super) fn as_inner(&self) -> &VerifyingKey { + &self.inner } - /// Parse from SEC1 (un)compressed bytes. + /// The SEC1 header byte this key serializes with. + pub(super) fn sec1_prefix(&self) -> Sec1Byte { + let odd = self.to_compressed()[0] == Sec1Byte::CompOdd.to_base(); + match (self.form, odd) { + (PkForm::Compressed, false) => Sec1Byte::CompEven, + (PkForm::Compressed, true) => Sec1Byte::CompOdd, + (PkForm::Uncompressed, _) => Sec1Byte::Uncomp, + (PkForm::Hybrid, false) => Sec1Byte::HybridEven, + (PkForm::Hybrid, true) => Sec1Byte::HybridOdd, + } + } + + /// Switch the serialization form to uncompressed. + /// + /// A hybrid key also becomes plain uncompressed, dropping its parity hint. + pub fn decompress(&mut self) { + self.form = PkForm::Uncompressed; + } + + /// Parse from SEC1 compressed, uncompressed, or hybrid bytes. + /// + /// The encoding form is retained so that re-serializing reproduces the input + /// bytes exactly. /// /// # Errors /// - /// Returns [`EcdsaError::InvalidPublicKey`] when the bytes are not a SEC1 - /// encoding of a point on the curve. + /// Returns [`EcdsaError::InvalidPublicKey`] when the header byte is not a + /// SEC1 prefix, the length disagrees with the prefix, a hybrid prefix + /// contradicts the Y coordinate's parity, or the coordinates do not lie on + /// the curve. pub fn from_bytes(bytes: &[u8]) -> Result { - VerifyingKey::from_sec1_bytes(bytes) - .map(Self) - .map_err(|_| EcdsaError::InvalidPublicKey) + let prefix = bytes.first().and_then(|&b| Sec1Byte::from_base(b)); + match prefix { + Some(p @ (Sec1Byte::HybridEven | Sec1Byte::HybridOdd)) => { + if bytes.len() != ECDSA_PK_LEN + 1 || (bytes[ECDSA_PK_LEN] & 1 != 0) != (p == Sec1Byte::HybridOdd) { + return Err(EcdsaError::InvalidPublicKey); + } + let mut buf = [0u8; ECDSA_PK_LEN + 1]; + buf.copy_from_slice(bytes); + buf[0] = Sec1Byte::Uncomp.to_base(); + VerifyingKey::from_sec1_bytes(&buf) + .map(|key| Self { + inner: key, + form: PkForm::Hybrid, + }) + .map_err(|_| EcdsaError::InvalidPublicKey) + } + _ => { + let compressed = Compression::from(prefix.is_some_and(|s| s.is_compressed())); + VerifyingKey::from_sec1_bytes(bytes) + .map(|key| Self::from_inner(key, compressed)) + .map_err(|_| EcdsaError::InvalidPublicKey) + } + } + } + + /// Whether this key serializes as compressed. + pub fn is_compressed(&self) -> bool { + self.form == PkForm::Compressed + } + + /// Whether this key serializes in the legacy hybrid form. + pub fn is_hybrid(&self) -> bool { + self.form == PkForm::Hybrid } /// Serialize as 33-byte compressed SEC1. - pub fn to_bytes(&self) -> [u8; 33] { - let pt = self.0.to_encoded_point(true); + pub fn to_compressed(&self) -> [u8; 33] { + let pt = self.inner.to_encoded_point(true); let mut out = [0u8; 33]; out.copy_from_slice(pt.as_bytes()); out } + /// Serialize as 65-byte hybrid SEC1, restating the Y parity in the header. + pub fn to_hybrid(&self) -> [u8; 65] { + let mut out = self.to_uncompressed(); + out[0] = Sec1Byte::HybridEven.to_base() | (out[ECDSA_PK_LEN] & 1); + out + } + /// Serialize as 65-byte uncompressed SEC1. - pub fn to_uncompressed_bytes(&self) -> [u8; 65] { - let pt = self.0.to_encoded_point(false); + pub fn to_uncompressed(&self) -> [u8; 65] { + let pt = self.inner.to_encoded_point(false); let mut out = [0u8; 65]; out.copy_from_slice(pt.as_bytes()); out } - /// Verify a signature over a 32-byte prehashed message. + /// Recover a public key from a signature and its embedded recovery metadata. /// /// # Errors /// - /// Returns [`EcdsaError::VerifyFailed`] when the signature does not verify - /// under this key. - pub fn verify(&self, msg_hash: &[u8; 32], sig: &EcdsaSignature) -> Result<(), EcdsaError> { - self - .0 - .verify_prehash(msg_hash, sig.as_inner()) - .map_err(|_| EcdsaError::VerifyFailed) + /// Returns [`EcdsaError::RecoveryFailed`] when no public key satisfies the + /// signature and message. The embedded recovery id needs no check: it is in + /// `0..=3` by construction. + pub fn recover(msg_hash: &[u8; 32], sig: &EcdsaRecSignature) -> Result { + VerifyingKey::recover_from_prehash(msg_hash, sig.signature().as_inner(), sig.backend_recovery_id()) + .map(|key| Self::from_inner(key, Compression::from(sig.is_compressed()))) + .map_err(|_| EcdsaError::RecoveryFailed) } - /// Recover a public key from a signature, prehashed message, and recovery id. + /// Recover a public key from a compact recoverable signature. /// /// # Errors /// - /// Returns [`EcdsaError::RecoveryFailed`] when no key recovers from the - /// signature under `rid`. - pub fn recover(msg_hash: &[u8; 32], sig: &EcdsaSignature, rid: EcdsaRecoveryId) -> Result { - VerifyingKey::recover_from_prehash(msg_hash, sig.as_inner(), rid.as_inner()) - .map(Self) - .map_err(|_| EcdsaError::RecoveryFailed) + /// Returns [`EcdsaError::InvalidSignature`] when the bag's scalars are not a + /// well-formed signature, plus every error listed for + /// [`recover`](Self::recover). + pub fn recover_compact(msg_hash: &[u8; 32], sig: &EcdsaRecSigBytes) -> Result { + let parsed = EcdsaRecSignature::try_from(*sig)?; + Self::recover(msg_hash, &parsed) + } + + /// Verify a signature over a 32-byte prehashed message. + /// + /// Accepts anything that can view itself as a plain signature, so a + /// recoverable signature verifies without an explicit downcast. High-S + /// signatures are rejected, the underlying curve primitive checks `s` + /// before the curve arithmetic runs. + /// + /// # Errors + /// + /// Returns [`EcdsaError::VerifyFailed`] when the signature does not verify + /// against this key and message. + pub fn verify(&self, msg_hash: &[u8; 32], sig: impl AsRef) -> Result<(), EcdsaError> { + self + .inner + .verify_prehash(msg_hash, sig.as_ref().as_inner()) + .map_err(|_| EcdsaError::VerifyFailed) } } impl Hash for EcdsaPublicKey { fn hash(&self, state: &mut H) { - self.to_bytes().hash(state); + EcdsaPkBytes::from(self).as_bytes().hash(state); } } type_cvrt!(From for EcdsaPkBytes, |pk| { - Self(pk.to_bytes()) + let prefix = pk.sec1_prefix(); + match pk.form { + PkForm::Compressed => Self::from_raw(prefix, &pk.to_compressed()), + PkForm::Uncompressed => Self::from_raw(prefix, &pk.to_uncompressed()), + PkForm::Hybrid => Self::from_raw(prefix, &pk.to_hybrid()), + } }); type_cvrt!(TryFrom for EcdsaPublicKey, EcdsaError, |bytes| { - Self::from_bytes(&bytes.0) + Self::from_bytes(bytes.as_bytes()) }); #[cfg(test)] #[expect(clippy::unwrap_used, reason = "test code")] mod tests { use crate::ecdsa::tests::*; - use crate::ecdsa::{EcdsaPublicKey, EcdsaRecoveryId, EcdsaSignature}; + use crate::ecdsa::{ + Compression, EcdsaPkBytes, EcdsaPublicKey, EcdsaRecSigBytes, EcdsaRecSignature, EcdsaSecretKey, EcdsaSigBytes, + EcdsaSignature, + }; use crate::prelude::*; - use dash_dev::{arr_from_hex, assert_json_rt, Corpus}; + #[cfg(feature = "serde")] + use dash_dev::assert_json_rt; + use dash_dev::{arr_from_hex, Corpus}; + use dash_types::codec::{BaseCodec, Hashable}; use rstest::*; use serde::Deserialize; @@ -118,21 +238,89 @@ mod tests { #[rstest] fn compressed_roundtrip(alice_pk: EcdsaPublicKey) { - let bytes = alice_pk.to_bytes(); + let bytes = alice_pk.to_compressed(); assert_eq!(bytes.len(), 33); let restored = EcdsaPublicKey::from_bytes(&bytes).unwrap(); assert_eq!(restored, alice_pk); } #[rstest] - fn corpus_recover() { + fn corpus_recover_compact() { let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), "ecdsa_sign"); for v in corpus.vectors::("recover") { - let sig = EcdsaSignature::from_compact(&arr_from_hex::<64>(&v.sig)).unwrap(); - let rid = EcdsaRecoveryId::try_from(v.recovery_id).unwrap(); - let pk = EcdsaPublicKey::recover(&arr_from_hex::<32>(&v.msg), &sig, rid).unwrap(); - assert_eq!(pk.to_bytes(), arr_from_hex::<33>(&v.pk)); + let sig = EcdsaSigBytes::from(arr_from_hex::<64>(&v.sig)); + let compact = EcdsaRecSigBytes::from_parts(sig, v.recovery_id, Compression::Compressed).unwrap(); + let pk = EcdsaPublicKey::recover_compact(&arr_from_hex::<32>(&v.msg), &compact).unwrap(); + assert_eq!(pk.to_compressed(), arr_from_hex::<33>(&v.pk)); + } + } + + #[rstest] + fn hybrid_bag_converts(alice_pk: EcdsaPublicKey) { + let mut bytes = alice_pk.to_uncompressed(); + bytes[0] = 0x06 | (bytes[64] & 1); + let bag = EcdsaPkBytes::from_bytes(&bytes).unwrap(); + let ops = EcdsaPublicKey::try_from(bag).unwrap(); + assert!(!ops.is_compressed()); + assert!(ops.is_hybrid()); + } + + #[rstest] + #[case::compressed(0x02)] + #[case::uncompressed(0x04)] + #[case::hybrid(0x06)] + fn bag_roundtrip_is_byte_stable(#[case] kind: u8, alice_pk: EcdsaPublicKey) { + let bag_in = match kind { + 0x02 => EcdsaPkBytes::from_bytes(&alice_pk.to_compressed()), + 0x04 => EcdsaPkBytes::from_bytes(&alice_pk.to_uncompressed()), + _ => EcdsaPkBytes::from_bytes(&alice_pk.to_hybrid()), } + .unwrap(); + let ops = EcdsaPublicKey::try_from(bag_in).unwrap(); + let bag_out = EcdsaPkBytes::from(&ops); + assert_eq!(bag_in, bag_out); + assert_eq!(bag_in.hash(), bag_out.hash()); + } + + #[rstest] + fn codec_roundtrip_preserves_hybrid(alice_pk: EcdsaPublicKey) { + let bag = EcdsaPkBytes::from_bytes(&alice_pk.to_hybrid()).unwrap(); + let mut wire = Vec::new(); + bag.encode(&mut wire); + let decoded = EcdsaPublicKey::decode(&mut wire.as_slice()).unwrap(); + let mut rewire = Vec::new(); + decoded.encode(&mut rewire); + assert_eq!(wire, rewire); + } + + #[rstest] + fn hybrid_rejects_parity_mismatch(alice_pk: EcdsaPublicKey) { + let mut bytes = alice_pk.to_uncompressed(); + bytes[0] = 0x06 | ((bytes[64] & 1) ^ 1); + assert!(EcdsaPublicKey::from_bytes(&bytes).is_err()); + } + + #[rstest] + fn hybrid_roundtrip(alice_pk: EcdsaPublicKey) { + let bytes = alice_pk.to_hybrid(); + let parsed = EcdsaPublicKey::from_bytes(&bytes).unwrap(); + assert!(!parsed.is_compressed()); + assert_eq!(parsed.to_hybrid(), bytes); + // Hybrid and plain uncompressed are the same point but distinct wire forms, + // so they must be unequal. + let mut plain = alice_pk; + plain.decompress(); + assert_ne!(parsed, plain); + assert_eq!(parsed.to_uncompressed(), plain.to_uncompressed()); + } + + #[rstest] + fn decompress_drops_hybrid_hint(alice_pk: EcdsaPublicKey) { + let mut parsed = EcdsaPublicKey::from_bytes(&alice_pk.to_hybrid()).unwrap(); + assert!(parsed.is_hybrid()); + parsed.decompress(); + assert!(!parsed.is_hybrid()); + assert_eq!(EcdsaPkBytes::from(&parsed).as_bytes()[0], 0x04); } #[rstest] @@ -141,9 +329,10 @@ mod tests { } #[rstest] - fn recover_roundtrip(alice_pk: EcdsaPublicKey, alice_rec_sig: (EcdsaSignature, EcdsaRecoveryId)) { - let (sig, rid) = alice_rec_sig; - assert_eq!(EcdsaPublicKey::recover(&MSG, &sig, rid).unwrap(), alice_pk); + fn recover_roundtrip(alice_pk: EcdsaPublicKey, alice_sk: EcdsaSecretKey, alice_rec_sig: EcdsaRecSignature) { + let compact_sig = alice_sk.sign_compact(&MSG).unwrap(); + assert_eq!(EcdsaPublicKey::recover_compact(&MSG, &compact_sig).unwrap(), alice_pk); + assert_eq!(EcdsaPublicKey::recover(&MSG, &alice_rec_sig).unwrap(), alice_pk); } #[cfg(feature = "serde")] @@ -154,10 +343,12 @@ mod tests { #[rstest] fn uncompressed_roundtrip(alice_pk: EcdsaPublicKey) { - let bytes = alice_pk.to_uncompressed_bytes(); + let mut pk = alice_pk; + pk.decompress(); + let bytes = pk.to_uncompressed(); assert_eq!(bytes.len(), 65); let restored = EcdsaPublicKey::from_bytes(&bytes).unwrap(); - assert_eq!(restored, alice_pk); + assert_eq!(restored, pk); } #[rstest] diff --git a/pkgs/pkc/src/ecdsa/secret_bytes.rs b/pkgs/pkc/src/ecdsa/secret_bytes.rs index de22ebbc..8192539c 100644 --- a/pkgs/pkc/src/ecdsa/secret_bytes.rs +++ b/pkgs/pkc/src/ecdsa/secret_bytes.rs @@ -6,52 +6,111 @@ //! secp256k1 secret key byte bag. -use dash_types::{impl_sbyte, TypeId}; +use super::Compression; +use crate::prelude::*; + +use base58ck::{decode_check, encode_check}; use subtle::ConstantTimeEq; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use core::fmt; -/// Raw ECDSA secret key bytes. -#[derive(Clone, Default, TypeId, Zeroize, ZeroizeOnDrop)] -pub struct EcdsaSkBytes([u8; 32]); +/// Raw secp256k1 secret key length. +pub const ECDSA_SK_LEN: usize = 32; -impl_sbyte!(32, EcdsaSkBytes); +/// Raw ECDSA secret key bytes. +/// +/// Carries a compression flag that decides how the derived public key +/// serializes. The bytes are unvalidated: DER needs an in-range scalar, so the +/// wire codec lives in [`EcdsaSecretKey`](crate::ecdsa::EcdsaSecretKey). +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct EcdsaSkBytes { + inner: [u8; ECDSA_SK_LEN], + #[zeroize(skip)] + compressed: bool, +} impl EcdsaSkBytes { - /// Consumes the bag and returns the inner byte array. - pub fn into_bytes(self) -> [u8; 32] { - self.0 + /// Borrow the raw inner bytes. + pub const fn as_bytes(&self) -> &[u8; ECDSA_SK_LEN] { + &self.inner + } + + /// Wrap raw bytes with a compression flag. + pub const fn from_bytes(bytes: [u8; ECDSA_SK_LEN], compressed: Compression) -> Self { + Self { + inner: bytes, + compressed: compressed.is_compressed(), + } } - /// Borrows the inner byte array. - pub const fn as_bytes(&self) -> &[u8; 32] { - &self.0 + /// Whether the corresponding public key should be compressed. + pub const fn is_compressed(&self) -> bool { + self.compressed + } + + /// Decode a wallet import format-encoded private key. + /// + /// Returns `None` on a bad checksum, an unexpected version prefix, a length + /// outside 33 or 34 bytes, a malformed compression flag, or an all-zero + /// scalar. Scalars at or above the curve order still pass: range checking + /// belongs to [`EcdsaSecretKey`](crate::ecdsa::EcdsaSecretKey). + pub fn from_wif(s: &str, prefix: u8) -> Option { + let data = Zeroizing::new(decode_check(s).ok()?); + let result = match data.len() { + 33 if data[0] == prefix => { + let key: [u8; ECDSA_SK_LEN] = data[1..33].try_into().ok()?; + Some(Self::from_bytes(key, Compression::Uncompressed)) + } + 34 if data[0] == prefix && data[33] == 0x01 => { + let key: [u8; ECDSA_SK_LEN] = data[1..33].try_into().ok()?; + Some(Self::from_bytes(key, Compression::Compressed)) + } + _ => None, + }; + result.filter(|sk| !sk.is_null()) } /// Returns `true` when every byte is zero. pub fn is_null(&self) -> bool { - self.0.ct_eq(&[0u8; 32]).into() + self.inner.ct_eq(&[0u8; ECDSA_SK_LEN]).into() } -} -impl Eq for EcdsaSkBytes {} + /// Copy out the raw inner bytes. + pub fn to_bytes(&self) -> Zeroizing<[u8; ECDSA_SK_LEN]> { + Zeroizing::new(self.inner) + } -impl PartialEq for EcdsaSkBytes { - fn eq(&self, other: &Self) -> bool { - self.0.ct_eq(&other.0).into() + /// Encode as a wallet import format string. + /// + /// Returns `None` for the all-zero scalar, which [`from_wif`](Self::from_wif) + /// rejects. Scalars at or above the curve order still encode, as `from_wif` + /// defers that range check too. + pub fn to_wif(&self, prefix: u8) -> Option> { + if self.is_null() { + return None; + } + let mut buf = Zeroizing::new([0u8; 34]); + buf[0] = prefix; + buf[1..33].copy_from_slice(&self.inner); + if self.compressed { + buf[33] = 0x01; + Some(Zeroizing::new(encode_check(&buf[..34]))) + } else { + Some(Zeroizing::new(encode_check(&buf[..33]))) + } } } impl AsRef<[u8]> for EcdsaSkBytes { fn as_ref(&self) -> &[u8] { - &self.0 + &self.inner } } -impl AsRef<[u8; 32]> for EcdsaSkBytes { - fn as_ref(&self) -> &[u8; 32] { - &self.0 +impl AsRef<[u8; ECDSA_SK_LEN]> for EcdsaSkBytes { + fn as_ref(&self) -> &[u8; ECDSA_SK_LEN] { + &self.inner } } @@ -67,8 +126,111 @@ impl fmt::Display for EcdsaSkBytes { } } -impl From for [u8; 32] { - fn from(val: EcdsaSkBytes) -> Self { - val.0 +impl Eq for EcdsaSkBytes {} + +impl PartialEq for EcdsaSkBytes { + fn eq(&self, other: &Self) -> bool { + self.inner.ct_eq(&other.inner).into() && self.compressed == other.compressed + } +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test code")] +mod tests { + use super::{Compression, EcdsaSkBytes, ECDSA_SK_LEN}; + use crate::prelude::*; + + use rstest::*; + + #[rstest] + fn debug_redacts_inner() { + let sk = EcdsaSkBytes::from_bytes([0xffu8; ECDSA_SK_LEN], Compression::Compressed); + let dbg = format!("{sk:?}"); + assert_eq!(dbg, "EcdsaSkBytes(..)"); + assert!(!dbg.contains("ff")); + } + + #[rstest] + fn equality() { + let a = EcdsaSkBytes::from_bytes([1u8; ECDSA_SK_LEN], Compression::Compressed); + let b = EcdsaSkBytes::from_bytes([1u8; ECDSA_SK_LEN], Compression::Compressed); + let c = EcdsaSkBytes::from_bytes([2u8; ECDSA_SK_LEN], Compression::Compressed); + let d = EcdsaSkBytes::from_bytes([1u8; ECDSA_SK_LEN], Compression::Uncompressed); + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!(a, d, "same scalar but different compression"); + } + + #[rstest] + #[case::compressed(0x42, Compression::Compressed)] + #[case::uncompressed(0x01, Compression::Uncompressed)] + fn roundtrip(#[case] fill: u8, #[case] compressed: Compression) { + let bytes = [fill; ECDSA_SK_LEN]; + let sk = EcdsaSkBytes::from_bytes(bytes, compressed); + assert_eq!(*sk.to_bytes(), bytes); + assert_eq!(sk.as_bytes(), &bytes); + assert_eq!(sk.is_compressed(), compressed.is_compressed()); + } + + #[rstest] + #[case::compressed(Compression::Compressed)] + #[case::uncompressed(Compression::Uncompressed)] + fn wif_roundtrip(#[case] compressed: Compression) { + let sk = EcdsaSkBytes::from_bytes([0x11u8; ECDSA_SK_LEN], compressed); + let wif = sk.to_wif(0x80).unwrap(); + let restored = EcdsaSkBytes::from_wif(&wif, 0x80).unwrap(); + assert_eq!(restored, sk); + } + + /// The encoder must not emit a string the decoder refuses to read back. + #[rstest] + #[case::compressed(Compression::Compressed)] + #[case::uncompressed(Compression::Uncompressed)] + fn to_wif_refuses_zero_scalar(#[case] compressed: Compression) { + let zero = EcdsaSkBytes::from_bytes([0u8; ECDSA_SK_LEN], compressed); + assert!(zero.to_wif(0x80).is_none()); + } + + /// A well-formed WIF carrying the zero scalar, assembled by hand because + /// `to_wif` refuses to emit one; `from_wif` must still reject it. + fn wif_zero_key() -> String { + let mut payload = [0u8; 34]; + payload[0] = 0x80; + payload[33] = 0x01; + base58ck::encode_check(&payload) + } + + fn wif_bad_checksum() -> String { + let sk = EcdsaSkBytes::from_bytes([0x22u8; ECDSA_SK_LEN], Compression::Compressed); + let mut raw = base58ck::decode(&sk.to_wif(0x80).unwrap()).unwrap(); + *raw.last_mut().unwrap() ^= 0xff; + base58ck::encode(&raw) + } + + fn wif_wrong_prefix() -> String { + let sk = EcdsaSkBytes::from_bytes([0x33u8; ECDSA_SK_LEN], Compression::Compressed); + (*sk.to_wif(0x80).unwrap()).clone() + } + + fn wif_wrong_length() -> String { + base58ck::encode_check(&[0x80u8; 32]) + } + + fn wif_bad_compression_byte() -> String { + let mut payload = [0u8; 34]; + payload[0] = 0x80; + payload[1..33].copy_from_slice(&[0x44u8; ECDSA_SK_LEN]); + payload[33] = 0x02; + base58ck::encode_check(&payload) + } + + #[rstest] + #[case::zero_key(wif_zero_key(), 0x80)] + #[case::bad_checksum(wif_bad_checksum(), 0x80)] + #[case::wrong_prefix(wif_wrong_prefix(), 0xef)] + #[case::wrong_length(wif_wrong_length(), 0x80)] + #[case::bad_compression_byte(wif_bad_compression_byte(), 0x80)] + fn wif_rejects(#[case] wif: String, #[case] prefix: u8) { + assert!(EcdsaSkBytes::from_wif(&wif, prefix).is_none()); } } diff --git a/pkgs/pkc/src/ecdsa/secret_ops.rs b/pkgs/pkc/src/ecdsa/secret_ops.rs index d928bb08..7432c87f 100644 --- a/pkgs/pkc/src/ecdsa/secret_ops.rs +++ b/pkgs/pkc/src/ecdsa/secret_ops.rs @@ -8,34 +8,209 @@ use super::error::EcdsaError; use super::public_ops::EcdsaPublicKey; -use super::secret_bytes::EcdsaSkBytes; -use super::sig_ops::{EcdsaRecoveryId, EcdsaSignature}; +use super::secret_bytes::{EcdsaSkBytes, ECDSA_SK_LEN}; +use super::sig_ops::EcdsaSignature; +use super::sig_rec_ops::EcdsaRecSignature; +use super::{Compression, EcdsaRecSigBytes}; -use dash_types::type_cvrt; +use bitcoin_hashes::sha256d; +use dash_num::Hash256; +use dash_types::codec::{ensure, ArrayBuf, BaseCodec, DecodeError, EncodeBuf, Hashable}; +use dash_types::{impl_stype, type_cvrt, TypeId}; +use hex_conservative::hex; use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey}; +use k256::elliptic_curve::ops::Neg; +use k256::{elliptic_curve::sec1::ToEncodedPoint, AffinePoint}; +use rand_core::CryptoRngCore; +use zeroize::{Zeroize, Zeroizing}; use core::fmt; +/// DER lengths of a private key with a compressed and an uncompressed public +/// key respectively. +const DER_SIZES: &[usize] = &[214, 279]; +/// ASN.1 object identifier for a prime-field curve. +const OID_PRIME_FIELD: &[u8] = &hex!("2a8648ce3d0101"); +/// secp256k1 field prime. +const PRIME: &[u8; ECDSA_SK_LEN] = &hex!("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"); +/// secp256k1 group order. +pub(super) const ORDER: &[u8; ECDSA_SK_LEN] = &hex!("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + +/// Emit a DER header followed by `bytes`. +fn der_bytes(buf: &mut impl EncodeBuf, tag: u8, bytes: &[u8]) { + der_header(buf, tag, bytes.len()); + buf.extend_from_slice(bytes); // nosemgrep: codec-no-raw-extend +} + +/// Emit a DER tag and its short, one-byte, or two-byte length. +fn der_header(buf: &mut impl EncodeBuf, tag: u8, len: usize) { + debug_assert!(len <= u16::MAX as usize, "der_header: length exceeds u16"); + let [hi, lo] = (len as u16).to_be_bytes(); + match len { + 0..=0x7f => buf.extend_from_slice(&[tag, lo]), // nosemgrep: codec-no-raw-extend + 0x80..=0xff => buf.extend_from_slice(&[tag, 0x81, lo]), // nosemgrep: codec-no-raw-extend + _ => buf.extend_from_slice(&[tag, 0x82, hi, lo]), // nosemgrep: codec-no-raw-extend + } +} + +/// Emit a DER INTEGER, prefixing a zero byte when the high bit is set. +fn der_uint(buf: &mut impl EncodeBuf, bytes: &[u8]) { + debug_assert!(!bytes.is_empty(), "der_uint: empty input"); + der_header(buf, 2, bytes.len() + usize::from(bytes[0] >= 0x80)); + if bytes[0] >= 0x80 { + buf.push(0); + } + buf.extend_from_slice(bytes); // nosemgrep: codec-no-raw-extend +} + /// A secp256k1 secret key. -#[derive(Clone)] -pub struct EcdsaSecretKey(SigningKey); +#[derive(Clone, TypeId)] +pub struct EcdsaSecretKey { + inner: SigningKey, + compressed: bool, +} + +impl BaseCodec for EcdsaSecretKey { + fn decode(data: &mut &[u8]) -> Result> { + ensure(data, 4).map_err(|e| e.lift())?; + if data[0] != 0x30 { + return Err(DecodeError::DecError(EcdsaError::MalformedDer)); + } + let (len, off) = match data[1] { + 0x81 => (usize::from(data[2]) + 3, 3), + 0x82 => ((usize::from(data[2]) << 8 | usize::from(data[3])) + 4, 4), + _ => return Err(DecodeError::DecError(EcdsaError::MalformedDer)), + }; + let compressed = len == DER_SIZES[0]; + if !DER_SIZES.contains(&len) { + return Err(DecodeError::BadLen { + expected: DER_SIZES.to_vec(), + actual: len, + }); + } + if data.len() < len { + return Err(DecodeError::Eof { + needed: len, + remaining: data.len(), + }); + } + if data[off..off + 5] != [2, 1, 1, 4, 32] { + return Err(DecodeError::DecError(EcdsaError::MalformedDer)); + } + let mut key = Zeroizing::new([0; ECDSA_SK_LEN]); + key.copy_from_slice(&data[off + 5..off + 5 + ECDSA_SK_LEN]); + let candidate = Self::from_bytes(&key, Compression::from(compressed)).map_err(DecodeError::DecError)?; + + // The curve OID, field prime, generator, order, and embedded public key are + // never read back individually; instead the scalar is re-encoded and + // compared byte-for-byte, so any tampering anywhere in the structure, + // including a public key that does not match the scalar, gets rejected. + let mut expected = Zeroizing::new(ArrayBuf::<{ DER_SIZES[1] }>::new()); + candidate.encode(&mut *expected); + if expected.as_bytes() != &data[..len] { + return Err(DecodeError::DecError(EcdsaError::MalformedDer)); + } + + *data = &data[len..]; + Ok(candidate) + } + + /// Encodes this key's raw scalar into `buf` as DER. + /// + /// `buf` receives the secret scalar in plain bytes and is not zeroized by + /// this function; callers who need the encoded form not to outlive its use + /// must supply a zeroizing buffer (e.g. + /// [`ArrayBuf`](dash_types::codec::ArrayBuf)) and zeroize or drop it + /// themselves once done. + fn encode(&self, buf: &mut impl EncodeBuf) { + let scalar = self.to_bytes(); + let public = self.inner.verifying_key().to_encoded_point(self.compressed); + let public = public.as_bytes(); + let generator = AffinePoint::GENERATOR.to_encoded_point(self.compressed); + let generator = generator.as_bytes(); + let point_len = public.len(); + let params_len = point_len + 97; + + der_header(buf, 0x30, 2 * point_len + 145); + der_uint(buf, &[1]); + der_bytes(buf, 4, &*scalar); + der_header(buf, 0xa0, params_len + 3); + der_header(buf, 0x30, params_len); + der_uint(buf, &[1]); + der_header(buf, 0x30, 44); + der_bytes(buf, 6, OID_PRIME_FIELD); + der_uint(buf, PRIME); + der_header(buf, 0x30, 6); + der_bytes(buf, 4, &[0]); + der_bytes(buf, 4, &[7]); + der_bytes(buf, 4, generator); + der_uint(buf, ORDER); + der_uint(buf, &[1]); + der_header(buf, 0xa1, point_len + 3); + der_header(buf, 3, point_len + 1); + buf.push(0); + buf.extend_from_slice(public); // nosemgrep: codec-no-raw-extend + } +} + +impl_stype!(EcdsaSecretKey, DER_SIZES[1], EcdsaError); + +impl Hashable for EcdsaSecretKey { + type Hash = Hash256; + + fn hash(&self) -> Hash256 { + let mut buf = Zeroizing::new(ArrayBuf::<{ DER_SIZES[1] }>::new()); + self.encode(&mut *buf); + Hash256::from_bytes(sha256d::Hash::hash(buf.as_bytes()).to_byte_array()) + } +} impl EcdsaSecretKey { /// Parse a secret key from a 32-byte big-endian scalar. - pub fn from_bytes(bytes: &[u8; 32]) -> Result { + /// + /// # Errors + /// + /// Returns [`EcdsaError::InvalidSecretKey`] when the scalar is zero or not + /// below the curve order. + pub fn from_bytes(bytes: &[u8; 32], compressed: Compression) -> Result { SigningKey::from_bytes(bytes.into()) - .map(Self) + .map(|key| Self { + inner: key, + compressed: compressed.is_compressed(), + }) .map_err(|_| EcdsaError::InvalidSecretKey) } - /// Serialize to a 32-byte big-endian scalar. - pub fn to_bytes(&self) -> [u8; 32] { - self.0.to_bytes().into() + /// Generate a new random secret key. + pub fn generate(rng: &mut impl CryptoRngCore, compressed: Compression) -> Self { + Self { + inner: SigningKey::random(rng), + compressed: compressed.is_compressed(), + } + } + + /// Whether the corresponding public key should be compressed. + pub fn is_compressed(&self) -> bool { + self.compressed + } + + /// Negate the secret scalar in place. + pub fn negate(&mut self) { + let neg = self.inner.as_nonzero_scalar().neg(); + self.inner = SigningKey::from(neg); } /// Derive the corresponding public key. pub fn public_key(&self) -> EcdsaPublicKey { - EcdsaPublicKey::from_inner(*self.0.verifying_key()) + EcdsaPublicKey::from_inner(*self.inner.verifying_key(), Compression::from(self.compressed)) + } + + /// Serialize to a 32-byte big-endian scalar. + pub fn to_bytes(&self) -> Zeroizing<[u8; ECDSA_SK_LEN]> { + let mut fb = self.inner.to_bytes(); + let out = Zeroizing::new(fb.into()); + <[u8]>::zeroize(fb.as_mut()); + out } /// Produce an ECDSA signature over a 32-byte prehashed message (RFC 6979, @@ -47,26 +222,45 @@ impl EcdsaSecretKey { /// the prehash. pub fn sign(&self, msg_hash: &[u8; 32]) -> Result { self - .0 + .inner .sign_prehash(msg_hash) .map(EcdsaSignature::from_inner) .map_err(|_| EcdsaError::SigningFailed) } - /// Sign and return the recovery id needed to recover the public - /// key from the signature. + /// Sign and return the compact recoverable signature bytes. + /// + /// # Errors + /// + /// Returns [`EcdsaError::SigningFailed`] if the underlying library rejects + /// the prehash. + pub fn sign_compact(&self, msg_hash: &[u8; 32]) -> Result { + Ok(self.sign_recoverable(msg_hash)?.into()) + } + + /// Sign and return a recoverable signature (RFC 6979, low-S normalised). + /// Recovery embeds the key's compression flag in the signature. /// /// # Errors /// - /// Returns [`EcdsaError::SigningFailed`] if the underlying library - /// rejects the prehash. - pub fn sign_recoverable(&self, msg_hash: &[u8; 32]) -> Result<(EcdsaSignature, EcdsaRecoveryId), EcdsaError> { + /// Returns [`EcdsaError::SigningFailed`] if the underlying library rejects + /// the prehash. + pub fn sign_recoverable(&self, msg_hash: &[u8; 32]) -> Result { self - .0 + .inner .sign_prehash(msg_hash) - .map(|(sig, rid)| (EcdsaSignature::from_inner(sig), EcdsaRecoveryId::from_inner(rid))) + .map(|(sig, rid)| EcdsaRecSignature::from_inner(sig, rid, Compression::from(self.compressed))) .map_err(|_| EcdsaError::SigningFailed) } + + /// Verify that a public key matches this secret key. + /// + /// Compares only the curve point: a caller-supplied key that serializes in a + /// different SEC1 form than this secret key's own preference still matches if + /// it is the same point. + pub fn verify_pubkey(&self, pubkey: &EcdsaPublicKey) -> bool { + self.inner.verifying_key() == pubkey.as_inner() + } } impl fmt::Debug for EcdsaSecretKey { @@ -75,22 +269,32 @@ impl fmt::Debug for EcdsaSecretKey { } } +impl Eq for EcdsaSecretKey {} + +impl PartialEq for EcdsaSecretKey { + fn eq(&self, other: &Self) -> bool { + use subtle::ConstantTimeEq; + (*self.to_bytes()).ct_eq(&*other.to_bytes()).into() && self.compressed == other.compressed + } +} + type_cvrt!(From for EcdsaSkBytes, |sk| { - Self::from(sk.to_bytes()) + Self::from_bytes(*sk.to_bytes(), Compression::from(sk.is_compressed())) }); type_cvrt!(TryFrom for EcdsaSecretKey, EcdsaError, |bytes| { - Self::from_bytes(bytes.as_bytes()) + Self::from_bytes(bytes.as_bytes(), Compression::from(bytes.is_compressed())) }); #[cfg(test)] -#[expect(clippy::unwrap_used, reason = "test code")] +#[expect(clippy::ptr_arg, clippy::unwrap_used, reason = "test code")] mod tests { use crate::ecdsa::tests::*; - use crate::ecdsa::{EcdsaPublicKey, EcdsaSecretKey}; + use crate::ecdsa::{Compression, EcdsaPublicKey, EcdsaSecretKey}; use crate::prelude::*; use dash_dev::{arr_from_hex, Corpus}; + use dash_types::codec::BaseCodec; use rstest::*; use serde::Deserialize; @@ -108,12 +312,88 @@ mod tests { recovery_id: u8, } + #[rstest] + #[case::compressed(Compression::Compressed)] + #[case::uncompressed(Compression::Uncompressed)] + fn codec_roundtrip_preserves_compression(#[case] compressed: Compression) { + let sk = EcdsaSecretKey::from_bytes(&ALICE_SK, compressed).unwrap(); + let mut buf = Vec::new(); + sk.encode(&mut buf); + let decoded = EcdsaSecretKey::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(*decoded.to_bytes(), *sk.to_bytes()); + assert_eq!(decoded.is_compressed(), compressed.is_compressed()); + } + + #[rstest] + #[case::compressed(Compression::Compressed)] + #[case::uncompressed(Compression::Uncompressed)] + fn consensus_bridge_roundtrip(#[case] compressed: Compression) { + use bitcoin_consensus_encoding::{encode_to_vec, Decodable, Decoder}; + + let sk = EcdsaSecretKey::from_bytes(&ALICE_SK, compressed).unwrap(); + let wire = encode_to_vec(&sk); + + let mut direct = Vec::new(); + sk.encode(&mut direct); + assert_eq!(wire, direct, "bridge must match the BaseCodec image"); + assert_eq!(wire.len(), if compressed.is_compressed() { 214 } else { 279 }); + + let mut dec = ::decoder(); + let mut cursor = wire.as_slice(); + while dec.push_bytes(&mut cursor).unwrap() && !cursor.is_empty() {} + let back = dec.end().unwrap(); + assert_eq!(*back.to_bytes(), *sk.to_bytes()); + assert_eq!(back.is_compressed(), compressed.is_compressed()); + } + + fn corrupt_mismatched_embedded_pubkey(buf: &mut Vec, alice: &EcdsaSecretKey, bob: &EcdsaSecretKey) { + let point_len = alice.public_key().to_compressed().len(); + let start = buf.len() - point_len; + buf[start..].copy_from_slice(&bob.public_key().to_compressed()); + } + + fn corrupt_tampered_curve_oid(buf: &mut Vec, _alice: &EcdsaSecretKey, _bob: &EcdsaSecretKey) { + let pos = buf + .windows(super::OID_PRIME_FIELD.len()) + .position(|w| w == super::OID_PRIME_FIELD) + .unwrap(); + buf[pos] ^= 0xff; + } + + fn corrupt_length_form_mismatch(buf: &mut Vec, _alice: &EcdsaSecretKey, _bob: &EcdsaSecretKey) { + // Overwrite the compressed point's SEC1 prefix byte with the uncompressed + // tag, keeping the (compressed) total length unchanged: the embedded + // point no longer matches a re-encoding of the scalar. + let point_start = buf.len() - 33; + buf[point_start] = 0x04; + } + + fn corrupt_truncated_body(buf: &mut Vec, _alice: &EcdsaSecretKey, _bob: &EcdsaSecretKey) { + buf.truncate(buf.len() - 1); + } + + #[rstest] + #[case::mismatched_embedded_pubkey(corrupt_mismatched_embedded_pubkey)] + #[case::tampered_curve_oid(corrupt_tampered_curve_oid)] + #[case::length_form_mismatch(corrupt_length_form_mismatch)] + #[case::truncated_body(corrupt_truncated_body)] + fn decode_rejects_malformed_der( + alice_sk: EcdsaSecretKey, + bob_sk: EcdsaSecretKey, + #[case] corrupt: fn(&mut Vec, &EcdsaSecretKey, &EcdsaSecretKey), + ) { + let mut buf = Vec::new(); + alice_sk.encode(&mut buf); + corrupt(&mut buf, &alice_sk, &bob_sk); + assert!(EcdsaSecretKey::decode(&mut buf.as_slice()).is_err()); + } + #[rstest] fn corpus_derive_pk() { let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), "ecdsa_keygen"); for v in corpus.vectors::("derive_pk") { - let sk = EcdsaSecretKey::from_bytes(&arr_from_hex(&v.sk)).unwrap(); - assert_eq!(sk.public_key().to_bytes(), arr_from_hex::<33>(&v.pk_compressed)); + let sk = EcdsaSecretKey::from_bytes(&arr_from_hex(&v.sk), Compression::Compressed).unwrap(); + assert_eq!(sk.public_key().to_compressed(), arr_from_hex::<33>(&v.pk_compressed)); } } @@ -121,23 +401,36 @@ mod tests { fn corpus_sign_recoverable() { let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), "ecdsa_sign"); for v in corpus.vectors::("sign_recoverable") { - let sk = EcdsaSecretKey::from_bytes(&arr_from_hex(&v.sk)).unwrap(); - let (sig, rid) = sk.sign_recoverable(&arr_from_hex::<32>(&v.msg)).unwrap(); + let sk = EcdsaSecretKey::from_bytes(&arr_from_hex(&v.sk), Compression::Compressed).unwrap(); + let sig = sk.sign_recoverable(&arr_from_hex::<32>(&v.msg)).unwrap(); assert_eq!(sig.to_compact(), arr_from_hex::<64>(&v.sig)); - assert_eq!(u8::from(rid), v.recovery_id); + assert_eq!(sig.recovery_id(), v.recovery_id); } } #[rstest] fn from_bytes_roundtrip(alice_sk: EcdsaSecretKey) { let bytes = alice_sk.to_bytes(); - let restored = EcdsaSecretKey::from_bytes(&bytes).unwrap(); - assert_eq!(restored.public_key().to_bytes(), alice_sk.public_key().to_bytes()); + let restored = EcdsaSecretKey::from_bytes(&bytes, Compression::Compressed).unwrap(); + assert_eq!( + restored.public_key().to_compressed(), + alice_sk.public_key().to_compressed() + ); + } + + #[rstest] + fn negate_changes_key(alice_sk: EcdsaSecretKey) { + let original_bytes = alice_sk.to_bytes(); + let mut negated = alice_sk.clone(); + negated.negate(); + assert_ne!(*negated.to_bytes(), *original_bytes); + negated.negate(); + assert_eq!(*negated.to_bytes(), *original_bytes); } #[rstest] fn rejects_zero() { - assert!(EcdsaSecretKey::from_bytes(&[0u8; 32]).is_err()); + assert!(EcdsaSecretKey::from_bytes(&[0u8; 32], Compression::Compressed).is_err()); } #[rstest] @@ -149,8 +442,8 @@ mod tests { #[rstest] fn sign_recoverable_roundtrip(alice_sk: EcdsaSecretKey) { - let (sig, rid) = alice_sk.sign_recoverable(&MSG).unwrap(); - let recovered = EcdsaPublicKey::recover(&MSG, &sig, rid).unwrap(); + let sig = alice_sk.sign_recoverable(&MSG).unwrap(); + let recovered = EcdsaPublicKey::recover(&MSG, &sig).unwrap(); assert_eq!(recovered, alice_sk.public_key()); } @@ -160,8 +453,21 @@ mod tests { assert!(alice_sk.public_key().verify(&MSG, &sig).is_ok()); } + #[rstest] + fn verify_pubkey_matches(alice_sk: EcdsaSecretKey) { + assert!(alice_sk.verify_pubkey(&alice_sk.public_key())); + } + + #[rstest] + fn verify_pubkey_matches_regardless_of_form(alice_sk: EcdsaSecretKey) { + let mut uncompressed = alice_sk.public_key(); + uncompressed.decompress(); + assert!(alice_sk.verify_pubkey(&uncompressed)); + } + #[rstest] fn verify_rejects_wrong_key(alice_sk: EcdsaSecretKey, bob_sk: EcdsaSecretKey) { + assert!(!alice_sk.verify_pubkey(&bob_sk.public_key())); let sig = alice_sk.sign(&MSG).unwrap(); assert!(bob_sk.public_key().verify(&MSG, &sig).is_err()); } diff --git a/pkgs/pkc/src/ecdsa/sig_bytes.rs b/pkgs/pkc/src/ecdsa/sig_bytes.rs index 6453c8e6..06451990 100644 --- a/pkgs/pkc/src/ecdsa/sig_bytes.rs +++ b/pkgs/pkc/src/ecdsa/sig_bytes.rs @@ -6,9 +6,135 @@ //! secp256k1 signature byte bag. -use dash_types::make_bytes; +use crate::prelude::*; -make_bytes! { - /// Raw compact ECDSA signature bytes. - EcdsaSigBytes, 64 +use bitcoin_hashes::sha256d; +use cfg_if::cfg_if; +use dash_num::Hash256; +use dash_types::codec::{ + read_bytes, read_compact_size, write_compact_size, BaseCodec, DecodeError, EncodeBuf, Hashable, +}; +use dash_types::{impl_type, type_cvrt, TypeId}; + +use core::fmt; + +/// Raw secp256k1 signature (r || s) length. +pub const ECDSA_SIG_LEN: usize = 64; + +/// Raw compact ECDSA signature bytes (r || s, unvalidated scalars). +#[derive(Clone, Copy, Eq, Hash, PartialEq, TypeId)] +pub struct EcdsaSigBytes([u8; ECDSA_SIG_LEN]); + +impl BaseCodec for EcdsaSigBytes { + fn decode(data: &mut &[u8]) -> Result { + let n = read_compact_size(data, ECDSA_SIG_LEN)?; + if n != ECDSA_SIG_LEN { + return Err(DecodeError::BadLen { + expected: vec![ECDSA_SIG_LEN], + actual: n, + }); + } + let mut arr = [0u8; ECDSA_SIG_LEN]; + arr.copy_from_slice(read_bytes(data, n)?); + Ok(Self(arr)) + } + + fn encode(&self, buf: &mut impl EncodeBuf) { + write_compact_size(self.0.len(), buf); + buf.extend_from_slice(&self.0); // nosemgrep: codec-no-raw-extend + } +} + +impl_type!(EcdsaSigBytes); + +impl Hashable for EcdsaSigBytes { + type Hash = Hash256; + + fn hash(&self) -> Hash256 { + Hash256::from_bytes(sha256d::Hash::hash(&self.0).to_byte_array()) + } +} + +impl EcdsaSigBytes { + /// Borrow the raw inner bytes. + pub const fn as_bytes(&self) -> &[u8; ECDSA_SIG_LEN] { + &self.0 + } + + /// Copy out the raw inner bytes. + pub const fn to_bytes(&self) -> [u8; ECDSA_SIG_LEN] { + self.0 + } +} + +impl fmt::Debug for EcdsaSigBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "EcdsaSigBytes({self})") + } +} + +impl fmt::Display for EcdsaSigBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in &self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +type_cvrt!(From<[u8; ECDSA_SIG_LEN]> for EcdsaSigBytes, |bytes| { + Self(*bytes) +}); + +cfg_if! { + if #[cfg(feature = "serde")] { + use dash_types::serialize::hex as serde_hex; + use serde::de::Error as DeError; + use serde::{Deserializer, Serializer}; + + impl ::serde::Serialize for EcdsaSigBytes { + fn serialize(&self, serializer: S) -> Result { + serde_hex::serialize(&self.0, serializer) + } + } + + impl<'de> ::serde::Deserialize<'de> for EcdsaSigBytes { + fn deserialize>(deserializer: D) -> Result { + serde_hex::deserialize(deserializer)? + .as_slice() + .try_into() + .map(Self) + .map_err(|_| DeError::custom("invalid compact signature length")) + } + } + } +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test code")] +mod tests { + use super::{EcdsaSigBytes, ECDSA_SIG_LEN}; + use crate::prelude::*; + + use dash_types::codec::BaseCodec; + use rstest::*; + + #[rstest] + fn codec_is_length_prefixed() { + let sb = EcdsaSigBytes::from([0xab; ECDSA_SIG_LEN]); + let mut buf = Vec::new(); + sb.encode(&mut buf); + assert_eq!(buf.len(), ECDSA_SIG_LEN + 1); + assert_eq!(buf[0] as usize, ECDSA_SIG_LEN); + let decoded = EcdsaSigBytes::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(decoded, sb); + } + + #[rstest] + fn roundtrip() { + let bytes = [0x42; ECDSA_SIG_LEN]; + let sb = EcdsaSigBytes::from(bytes); + assert_eq!(sb.as_bytes(), &bytes); + assert_eq!(sb.to_bytes(), bytes); + } } diff --git a/pkgs/pkc/src/ecdsa/sig_ops.rs b/pkgs/pkc/src/ecdsa/sig_ops.rs index 12dbdeb8..b26b2f24 100644 --- a/pkgs/pkc/src/ecdsa/sig_ops.rs +++ b/pkgs/pkc/src/ecdsa/sig_ops.rs @@ -7,15 +7,17 @@ //! secp256k1 signature. use super::error::EcdsaError; +use super::sig_bytes::ECDSA_SIG_LEN; use super::EcdsaSigBytes; -use dash_types::{type_cvrt, Unencodable}; -use k256::ecdsa::{DerSignature, RecoveryId, Signature}; +use dash_num::Hash256; +use dash_types::{dlgt_codec, type_cvrt, TypeId, Unencodable}; +use k256::ecdsa::{DerSignature, Signature}; use core::hash::{Hash, Hasher}; /// An ECDSA signature (64-byte compact r||s). -#[derive(Clone, Debug, Eq, PartialEq, Unencodable)] +#[derive(Clone, Debug, Eq, PartialEq, TypeId)] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr( feature = "serde", @@ -23,6 +25,8 @@ use core::hash::{Hash, Hasher}; )] pub struct EcdsaSignature(Signature); +dlgt_codec!(EcdsaSignature => EcdsaSigBytes, Hash256, EcdsaError, ECDSA_SIG_LEN + 1); + impl EcdsaSignature { pub(super) fn from_inner(inner: Signature) -> Self { Self(inner) @@ -34,11 +38,14 @@ impl EcdsaSignature { /// Parse from 64-byte compact format (r || s). /// + /// Accepts high-S signatures; see [`is_low_s`](Self::is_low_s) to reject + /// otherwise. + /// /// # Errors /// /// Returns [`EcdsaError::InvalidSignature`] when `r` or `s` is zero or not - /// less than the group order. - pub fn from_compact(bytes: &[u8; 64]) -> Result { + /// a scalar below the curve order. + pub fn from_compact(bytes: &[u8; ECDSA_SIG_LEN]) -> Result { Signature::from_slice(bytes) .map(Self) .map_err(|_| EcdsaError::InvalidSignature) @@ -48,22 +55,33 @@ impl EcdsaSignature { /// /// # Errors /// - /// Returns [`EcdsaError::InvalidSignature`] when the DER framing is malformed - /// or the scalars it carries are out of range. + /// Returns [`EcdsaError::InvalidSignature`] when the DER framing is + /// malformed or either scalar is out of range. pub fn from_der(bytes: &[u8]) -> Result { Signature::from_der(bytes) .map(Self) .map_err(|_| EcdsaError::InvalidSignature) } + /// Whether the S component is in the lower half of the curve order. + pub fn is_low_s(&self) -> bool { + self.0.normalize_s().is_none() + } + + /// Return a signature with the S value normalised to the lower half of the + /// curve order. Returns `None` if already normalised. + pub fn normalize_s(&self) -> Option { + self.0.normalize_s().map(Self) + } + /// Serialize as 64-byte compact format (r || s). - pub fn to_compact(&self) -> [u8; 64] { + pub fn to_compact(&self) -> [u8; ECDSA_SIG_LEN] { self.0.to_bytes().into() } - /// Encode as DER. - pub fn to_der(&self) -> EcdsaDerSignature { - EcdsaDerSignature(self.0.to_der()) + /// Encode as DER bytes. + pub fn to_der(&self) -> EcdsaDerSig { + EcdsaDerSig(self.0.to_der()) } } @@ -73,66 +91,17 @@ impl Hash for EcdsaSignature { } } -type_cvrt!(From for EcdsaSigBytes, |sig| { - Self(sig.to_compact()) -}); - -type_cvrt!(TryFrom for EcdsaSignature, EcdsaError, |bytes| { - Self::from_compact(&bytes.0) -}); - -/// Recovery id (0..3) used to recover a public key from an ECDSA -/// signature. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Unencodable)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(into = "u8", try_from = "u8"))] -pub struct EcdsaRecoveryId(RecoveryId); - -impl EcdsaRecoveryId { - pub(super) fn from_inner(inner: RecoveryId) -> Self { - Self(inner) - } - - pub(super) fn as_inner(&self) -> RecoveryId { - self.0 - } - - /// Create from a raw byte (0, 1, 2, or 3). - /// - /// # Errors - /// - /// Returns [`EcdsaError::InvalidRecoveryId`] when `id` is greater than 3. - pub fn new(id: u8) -> Result { - RecoveryId::try_from(id) - .map(Self) - .map_err(|_| EcdsaError::InvalidRecoveryId) - } - - /// Return the raw byte value. - pub fn to_byte(self) -> u8 { - self.0.to_byte() - } -} - -impl Hash for EcdsaRecoveryId { - fn hash(&self, state: &mut H) { - self.to_byte().hash(state); +impl AsRef for EcdsaSignature { + fn as_ref(&self) -> &EcdsaSignature { + self } } -type_cvrt!(From for u8, |rid| { - rid.to_byte() -}); - -type_cvrt!(TryFrom for EcdsaRecoveryId, EcdsaError, |byte| { - Self::new(*byte) -}); - /// DER-encoded ECDSA signature (variable length, typically 70-72 bytes). #[derive(Clone, Debug, Unencodable)] -pub struct EcdsaDerSignature(DerSignature); +pub struct EcdsaDerSig(DerSignature); -impl EcdsaDerSignature { +impl EcdsaDerSig { /// Raw DER bytes. pub fn as_bytes(&self) -> &[u8] { self.0.as_bytes() @@ -149,26 +118,35 @@ impl EcdsaDerSignature { } } -impl Eq for EcdsaDerSignature {} +impl Eq for EcdsaDerSig {} -impl Hash for EcdsaDerSignature { +impl Hash for EcdsaDerSig { fn hash(&self, state: &mut H) { self.as_bytes().hash(state); } } -impl PartialEq for EcdsaDerSignature { +impl PartialEq for EcdsaDerSig { fn eq(&self, other: &Self) -> bool { self.as_bytes() == other.as_bytes() } } +type_cvrt!(From for EcdsaSigBytes, |sig| { + Self::from(sig.to_compact()) +}); + +type_cvrt!(TryFrom for EcdsaSignature, EcdsaError, |bytes| { + Self::from_compact(bytes.as_bytes()) +}); + #[cfg(test)] #[expect(clippy::unwrap_used, reason = "test code")] mod tests { use crate::ecdsa::tests::*; - use crate::ecdsa::{EcdsaError, EcdsaRecoveryId, EcdsaSignature}; + use crate::ecdsa::{EcdsaPublicKey, EcdsaSigBytes, EcdsaSignature}; + #[cfg(feature = "serde")] use dash_dev::assert_json_rt; use rstest::*; @@ -179,6 +157,13 @@ mod tests { assert_eq!(restored, alice_sig); } + #[rstest] + fn bag_roundtrip(alice_sig: EcdsaSignature) { + let bag = EcdsaSigBytes::from(&alice_sig); + let restored = EcdsaSignature::try_from(bag).unwrap(); + assert_eq!(restored, alice_sig); + } + #[rstest] fn der_roundtrip(alice_sig: EcdsaSignature) { let der = alice_sig.to_der(); @@ -186,33 +171,42 @@ mod tests { assert_eq!(restored, alice_sig); } - #[cfg(feature = "serde")] #[rstest] - fn serde_sig_roundtrip(alice_sig: EcdsaSignature) { - assert_json_rt(&alice_sig); + fn der_bag_is_not_empty(alice_sig: EcdsaSignature) { + let der = alice_sig.to_der(); + assert!(!der.is_empty()); + assert_eq!(der.len(), der.as_bytes().len()); } #[rstest] - #[case(0)] - #[case(1)] - #[case(2)] - #[case(3)] - fn recovery_id_roundtrip(#[case] id: u8) { - let rid = EcdsaRecoveryId::new(id).unwrap(); - assert_eq!(rid.to_byte(), id); + fn is_low_s_after_signing(alice_sig: EcdsaSignature) { + // Library already produces low-S signatures. + assert!(alice_sig.is_low_s()); } #[rstest] - #[case(4)] - #[case(255)] - fn recovery_id_rejects_out_of_range(#[case] id: u8) { - assert_eq!(EcdsaRecoveryId::new(id), Err(EcdsaError::InvalidRecoveryId)); + fn normalize_s_noop_when_already_low(alice_sig: EcdsaSignature) { + assert!(alice_sig.normalize_s().is_none()); + } + + #[rstest] + fn normalize_s_flips_high_s_signature(alice_pk: EcdsaPublicKey, alice_sig: EcdsaSignature) { + let compact = alice_sig.to_compact(); + let mut high_bytes = [0u8; 64]; + high_bytes[..32].copy_from_slice(&compact[..32]); + high_bytes[32..].copy_from_slice(&negate_scalar(&compact[32..])); + let high_sig = EcdsaSignature::from_compact(&high_bytes).unwrap(); + assert!(!high_sig.is_low_s()); + + let normalized = high_sig.normalize_s().unwrap(); + assert!(normalized.is_low_s()); + assert_eq!(normalized, alice_sig); + assert!(alice_pk.verify(&MSG, &normalized).is_ok()); } #[cfg(feature = "serde")] #[rstest] - fn serde_recovery_id_roundtrip() { - let rid = EcdsaRecoveryId::new(1).unwrap(); - assert_json_rt(&rid); + fn serde_sig_roundtrip(alice_sig: EcdsaSignature) { + assert_json_rt(&alice_sig); } } diff --git a/pkgs/pkc/src/ecdsa/sig_rec_bytes.rs b/pkgs/pkc/src/ecdsa/sig_rec_bytes.rs new file mode 100644 index 00000000..04592d1f --- /dev/null +++ b/pkgs/pkc/src/ecdsa/sig_rec_bytes.rs @@ -0,0 +1,341 @@ +// +// Copyright (c) 2026-present, The Dash Core developers +// SPDX-License-Identifier: MIT +// See the accompanying file LICENSE or https://opensource.org/license/MIT +// + +//! secp256k1 compact recoverable signature byte bag. + +use super::sig_bytes::{EcdsaSigBytes, ECDSA_SIG_LEN}; +use super::Compression; +use crate::prelude::*; + +use bitcoin_hashes::sha256d; +use cfg_if::cfg_if; +use dash_num::Hash256; +use dash_types::codec::{ + read_bytes, read_compact_size, write_compact_size, BaseCodec, DecodeError, EncodeBuf, Hashable, +}; +use dash_types::{enum_map, impl_type, type_cvrt, TypeId}; + +use core::fmt; + +enum_map! { + /// Header flags for a compact recoverable ECDSA signature. + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + pub(super) enum CompactFlags, u8 { + /// Uncompressed key, recovery id 0. + Uncompressed0 = 27, + /// Uncompressed key, recovery id 1. + Uncompressed1 = 28, + /// Uncompressed key, recovery id 2. + Uncompressed2 = 29, + /// Uncompressed key, recovery id 3. + Uncompressed3 = 30, + /// Compressed key, recovery id 0. + Compressed0 = 31, + /// Compressed key, recovery id 1. + Compressed1 = 32, + /// Compressed key, recovery id 2. + Compressed2 = 33, + /// Compressed key, recovery id 3. + Compressed3 = 34, + } +} + +impl CompactFlags { + /// Whether the signing key was compressed. + pub const fn is_compressed(self) -> bool { + self.to_base() >= Self::Compressed0.to_base() + } + + /// Construct from recovery id and compression flag. + pub const fn new(recovery_id: u8, compressed: Compression) -> Option { + if recovery_id > 3 { + return None; + } + Some(Self::from_parts(recovery_id, compressed)) + } + + /// Construct from the low two bits of `recovery_id` and a compression flag. + /// + /// Total, unlike [`CompactFlags::new`]: the eight variants cover every + /// combination, so a caller holding an already range-checked recovery id + /// needs no fallible path. + pub const fn from_parts(recovery_id: u8, compressed: Compression) -> Self { + match (recovery_id & 3, compressed) { + (0, Compression::Uncompressed) => Self::Uncompressed0, + (1, Compression::Uncompressed) => Self::Uncompressed1, + (2, Compression::Uncompressed) => Self::Uncompressed2, + (_, Compression::Uncompressed) => Self::Uncompressed3, + (0, Compression::Compressed) => Self::Compressed0, + (1, Compression::Compressed) => Self::Compressed1, + (2, Compression::Compressed) => Self::Compressed2, + (_, Compression::Compressed) => Self::Compressed3, + } + } + + /// Recovery ID. + pub const fn recovery_id(self) -> u8 { + (self.to_base() - Self::Uncompressed0.to_base()) & 3 + } +} + +/// Compact recoverable ECDSA signature bytes: one header byte carrying the +/// recovery id and compression flag, then `r || s`. +#[derive(Clone, Copy, Eq, Hash, PartialEq, TypeId)] +pub struct EcdsaRecSigBytes { + flags: CompactFlags, + sig: EcdsaSigBytes, +} + +impl BaseCodec for EcdsaRecSigBytes { + fn decode(data: &mut &[u8]) -> Result { + let n = read_compact_size(data, ECDSA_SIG_LEN + 1)?; + if n != ECDSA_SIG_LEN + 1 { + return Err(DecodeError::BadLen { + expected: vec![ECDSA_SIG_LEN + 1], + actual: n, + }); + } + let raw = read_bytes(data, n)?; + let flags = CompactFlags::from_base(raw[0]).ok_or_else(|| DecodeError::InvalidValue { + expected: CompactFlags::variants() + .iter() + .map(|f| u64::from(f.to_base())) + .collect(), + actual: u64::from(raw[0]), + })?; + let mut arr = [0u8; ECDSA_SIG_LEN]; + arr.copy_from_slice(&raw[1..]); + Ok(Self { + flags, + sig: EcdsaSigBytes::from(arr), + }) + } + + fn encode(&self, buf: &mut impl EncodeBuf) { + write_compact_size(ECDSA_SIG_LEN + 1, buf); + buf.push(self.flags.to_base()); + let sig = self.sig.as_bytes(); + buf.extend_from_slice(sig); // nosemgrep: codec-no-raw-extend + } +} + +impl_type!(EcdsaRecSigBytes); + +impl Hashable for EcdsaRecSigBytes { + type Hash = Hash256; + + fn hash(&self) -> Hash256 { + Hash256::from_bytes(sha256d::Hash::hash(&self.to_bytes()).to_byte_array()) + } +} + +impl EcdsaRecSigBytes { + /// The validated header flags. + /// + /// Only library-backed operational types need the flags as a unit; the + /// bag's own accessors go through [`recovery_id`](Self::recovery_id) and + /// [`is_compressed`](Self::is_compressed). + #[cfg(feature = "ecdsa")] + pub(super) fn flags(&self) -> CompactFlags { + self.flags + } + + /// Construct from a plain signature bag and pre-validated flags. + #[cfg(feature = "ecdsa")] + pub(super) const fn from_flags(sig: EcdsaSigBytes, flags: CompactFlags) -> Self { + Self { flags, sig } + } + + /// Construct from a plain signature bag and recovery metadata. + /// + /// Returns `None` when `recovery_id` is outside `0..=3`. + pub fn from_parts(sig: EcdsaSigBytes, recovery_id: u8, compressed: Compression) -> Option { + Some(Self { + flags: CompactFlags::new(recovery_id, compressed)?, + sig, + }) + } + + /// Construct from a raw 65-byte buffer. + /// + /// Returns `None` when the header byte is outside the `27..=34` range that + /// encodes a recovery id and compression flag. + pub fn from_raw(bytes: [u8; ECDSA_SIG_LEN + 1]) -> Option { + let flags = CompactFlags::from_base(bytes[0])?; + let mut arr = [0u8; ECDSA_SIG_LEN]; + arr.copy_from_slice(&bytes[1..]); + Some(Self { + flags, + sig: EcdsaSigBytes::from(arr), + }) + } + + /// Whether the signing key was compressed. + pub fn is_compressed(&self) -> bool { + self.flags.is_compressed() + } + + /// Recovery ID. + pub fn recovery_id(&self) -> u8 { + self.flags.recovery_id() + } + + /// The plain signature bytes without the header. + pub fn signature(&self) -> EcdsaSigBytes { + self.sig + } + + /// The full 65-byte encoding. + pub fn to_bytes(&self) -> [u8; ECDSA_SIG_LEN + 1] { + let mut out = [0u8; ECDSA_SIG_LEN + 1]; + out[0] = self.flags.to_base(); + out[1..].copy_from_slice(self.sig.as_bytes()); + out + } +} + +impl fmt::Debug for EcdsaRecSigBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "EcdsaRecSigBytes(recid={}, compressed={})", + self.recovery_id(), + self.is_compressed() + ) + } +} + +impl fmt::Display for EcdsaRecSigBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.to_bytes() { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +type_cvrt!(From for EcdsaSigBytes, |rec| { + rec.signature() +}); + +cfg_if! { + if #[cfg(feature = "serde")] { + use dash_types::serialize::hex as serde_hex; + use serde::de::Error as DeError; + use serde::{Deserializer, Serializer}; + + impl ::serde::Serialize for EcdsaRecSigBytes { + fn serialize(&self, serializer: S) -> Result { + serde_hex::serialize(&self.to_bytes(), serializer) + } + } + + impl<'de> ::serde::Deserialize<'de> for EcdsaRecSigBytes { + fn deserialize>(deserializer: D) -> Result { + let arr: [u8; ECDSA_SIG_LEN + 1] = serde_hex::deserialize(deserializer)? + .as_slice() + .try_into() + .map_err(|_| DeError::custom("invalid compact recoverable signature length"))?; + Self::from_raw(arr).ok_or_else(|| DeError::custom("invalid compact signature header")) + } + } + } +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test code")] +mod tests { + use super::{CompactFlags, Compression, EcdsaRecSigBytes, EcdsaSigBytes, ECDSA_SIG_LEN}; + use crate::prelude::*; + + use dash_types::codec::{BaseCodec, DecodeError}; + use rstest::*; + + fn sig_bag(fill: u8) -> EcdsaSigBytes { + EcdsaSigBytes::from([fill; ECDSA_SIG_LEN]) + } + + #[rstest] + fn codec_is_length_prefixed() { + let rec = EcdsaRecSigBytes::from_parts(sig_bag(0xab), 1, Compression::Compressed).unwrap(); + let mut buf = Vec::new(); + rec.encode(&mut buf); + assert_eq!(buf.len(), ECDSA_SIG_LEN + 2); + assert_eq!(buf[0] as usize, ECDSA_SIG_LEN + 1); + let decoded = EcdsaRecSigBytes::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(decoded, rec); + } + + #[rstest] + fn decode_rejects_bad_header() { + let mut buf = Vec::new(); + EcdsaRecSigBytes::from_parts(sig_bag(0), 0, Compression::Uncompressed) + .unwrap() + .encode(&mut buf); + buf[1] = 0x00; + assert!(matches!( + EcdsaRecSigBytes::decode(&mut buf.as_slice()), + Err(DecodeError::InvalidValue { .. }) + )); + } + + #[rstest] + #[case(0, Compression::Uncompressed)] + #[case(0, Compression::Compressed)] + #[case(1, Compression::Uncompressed)] + #[case(1, Compression::Compressed)] + #[case(2, Compression::Uncompressed)] + #[case(2, Compression::Compressed)] + #[case(3, Compression::Uncompressed)] + #[case(3, Compression::Compressed)] + fn flags_roundtrip(#[case] rid: u8, #[case] compressed: Compression) { + let flags = CompactFlags::new(rid, compressed).unwrap(); + assert_eq!(flags.recovery_id(), rid); + assert_eq!(flags.is_compressed(), compressed.is_compressed()); + assert_eq!(CompactFlags::from_base(flags.to_base()), Some(flags)); + } + + #[rstest] + fn from_raw_rejects_bad_header() { + let mut buf = [0u8; ECDSA_SIG_LEN + 1]; + buf[0] = 0x00; + assert!(EcdsaRecSigBytes::from_raw(buf).is_none()); + + buf[0] = CompactFlags::Compressed3.to_base() + 1; + assert!(EcdsaRecSigBytes::from_raw(buf).is_none()); + } + + #[rstest] + fn header_byte_encoding() { + let rec = EcdsaRecSigBytes::from_parts(sig_bag(0), 1, Compression::Compressed).unwrap(); + assert_eq!(rec.to_bytes()[0], CompactFlags::Compressed1.to_base()); + + let rec = EcdsaRecSigBytes::from_parts(sig_bag(0), 3, Compression::Uncompressed).unwrap(); + assert_eq!(rec.to_bytes()[0], CompactFlags::Uncompressed3.to_base()); + } + + #[rstest] + #[case::valid_0(0, true)] + #[case::valid_1(1, true)] + #[case::valid_2(2, true)] + #[case::valid_3(3, true)] + #[case::out_of_range_4(4, false)] + #[case::out_of_range_255(255, false)] + fn recovery_id_range(#[case] rid: u8, #[case] valid: bool) { + let result = EcdsaRecSigBytes::from_parts(sig_bag(0), rid, Compression::Compressed); + assert_eq!(result.is_some(), valid); + if let Some(rec) = result { + assert_eq!(rec.recovery_id(), rid); + } + } + + #[rstest] + fn strips_header_to_plain_bag() { + let sig = sig_bag(0xcd); + let rec = EcdsaRecSigBytes::from_parts(sig, 2, Compression::Uncompressed).unwrap(); + assert_eq!(EcdsaSigBytes::from(rec), sig); + } +} diff --git a/pkgs/pkc/src/ecdsa/sig_rec_ops.rs b/pkgs/pkc/src/ecdsa/sig_rec_ops.rs new file mode 100644 index 00000000..d5ac6d8a --- /dev/null +++ b/pkgs/pkc/src/ecdsa/sig_rec_ops.rs @@ -0,0 +1,209 @@ +// +// Copyright (c) 2026-present, The Dash Core developers +// SPDX-License-Identifier: MIT +// See the accompanying file LICENSE or https://opensource.org/license/MIT +// + +//! secp256k1 recoverable signature. + +use super::error::EcdsaError; +use super::sig_bytes::{EcdsaSigBytes, ECDSA_SIG_LEN}; +use super::sig_ops::EcdsaSignature; +use super::sig_rec_bytes::{CompactFlags, EcdsaRecSigBytes}; +use super::Compression; + +use dash_num::Hash256; +use dash_types::{dlgt_codec, type_cvrt, TypeId}; +use k256::ecdsa::{RecoveryId, Signature}; + +/// An ECDSA signature with recovery id and compression metadata. +#[derive(Clone, Debug, Eq, Hash, PartialEq, TypeId)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(into = "EcdsaRecSigBytes", try_from = "EcdsaRecSigBytes"))] +pub struct EcdsaRecSignature { + sig: EcdsaSignature, + flags: CompactFlags, +} + +dlgt_codec!(EcdsaRecSignature => EcdsaRecSigBytes, Hash256, EcdsaError, ECDSA_SIG_LEN + 2); + +impl EcdsaRecSignature { + pub(super) fn from_inner(inner: Signature, recovery_id: RecoveryId, compressed: Compression) -> Self { + Self { + sig: EcdsaSignature::from_inner(inner), + flags: CompactFlags::from_parts(recovery_id.to_byte(), compressed), + } + } + + /// Attach recovery metadata to a plain signature. + /// + /// # Errors + /// + /// Returns [`EcdsaError::InvalidRecoveryId`] if `recovery_id` is not in + /// `0..=3`. + pub fn from_parts(sig: EcdsaSignature, recovery_id: u8, compressed: Compression) -> Result { + let flags = CompactFlags::new(recovery_id, compressed).ok_or(EcdsaError::InvalidRecoveryId)?; + Ok(Self { sig, flags }) + } + + /// Whether the signing key was compressed. + pub fn is_compressed(&self) -> bool { + self.flags.is_compressed() + } + + /// Return a signature with the S value normalised to the lower half of the + /// curve order. Returns `None` if already normalised. + pub fn normalize_s(&self) -> Option { + // Negating S mirrors R across the X axis: X is unchanged and the Y parity + // flips, so the recovery id toggles its low bit. + let sig = self.sig.normalize_s()?; + Some(Self { + sig, + flags: CompactFlags::from_parts(self.recovery_id() ^ 1, Compression::from(self.is_compressed())), + }) + } + + /// Recovery ID. + pub fn recovery_id(&self) -> u8 { + self.flags.recovery_id() + } + + /// The recovery id in the form the backend expects. + /// + /// Infallible, unlike [`RecoveryId::from_byte`]: `CompactFlags` encodes only + /// ids in `0..=3`, so both bits are in range by construction. + pub(super) const fn backend_recovery_id(&self) -> RecoveryId { + let id = self.flags.recovery_id(); + RecoveryId::new(id & 1 == 1, id & 2 == 2) + } + + /// The plain signature without recovery metadata. + pub fn signature(&self) -> &EcdsaSignature { + &self.sig + } + + /// Serialize as 64-byte compact format (r || s). + pub fn to_compact(&self) -> [u8; ECDSA_SIG_LEN] { + self.sig.to_compact() + } +} + +impl AsRef for EcdsaRecSignature { + fn as_ref(&self) -> &EcdsaSignature { + &self.sig + } +} + +// Infallible: `CompactFlags` covers every (id, compression) pair. +type_cvrt!(From for EcdsaRecSigBytes, |rec| { + Self::from_flags(EcdsaSigBytes::from(rec.signature()), rec.flags) +}); + +type_cvrt!(From for EcdsaSignature, |rec| { + rec.signature().clone() +}); + +type_cvrt!(TryFrom for EcdsaRecSignature, EcdsaError, |bytes| { + Ok(Self { + sig: EcdsaSignature::try_from(bytes.signature())?, + flags: bytes.flags(), + }) +}); + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test code")] +mod tests { + use crate::ecdsa::tests::*; + use crate::ecdsa::{Compression, EcdsaPublicKey, EcdsaRecSigBytes, EcdsaRecSignature, EcdsaSigBytes, EcdsaSignature}; + + #[cfg(feature = "serde")] + use dash_dev::assert_json_rt; + use rstest::*; + + /// The infallible bit-split must agree with the fallible byte parse it + /// replaced, for every id the flags can hold. + #[rstest] + #[case(0)] + #[case(1)] + #[case(2)] + #[case(3)] + fn backend_recovery_id_matches_byte(#[case] id: u8, alice_sig: EcdsaSignature) { + let rec = EcdsaRecSignature::from_parts(alice_sig, id, Compression::Compressed).unwrap(); + assert_eq!(rec.backend_recovery_id().to_byte(), id); + } + + #[rstest] + fn bag_roundtrip(alice_rec_sig: EcdsaRecSignature) { + let bag = EcdsaRecSigBytes::from(&alice_rec_sig); + let restored = EcdsaRecSignature::try_from(bag).unwrap(); + assert_eq!(restored, alice_rec_sig); + } + + #[rstest] + fn conversions_commute(alice_rec_sig: EcdsaRecSignature) { + // Both paths to the plain bag must agree: drop metadata then serialize, or + // serialize then strip the header. + let via_ops = EcdsaSigBytes::from(EcdsaSignature::from(alice_rec_sig.clone())); + let via_bag = EcdsaSigBytes::from(EcdsaRecSigBytes::from(&alice_rec_sig)); + assert_eq!(via_ops, via_bag); + } + + #[rstest] + fn from_parts_rejects_out_of_range_id(alice_sig: EcdsaSignature) { + assert!(EcdsaRecSignature::from_parts(alice_sig.clone(), 4, Compression::Compressed).is_err()); + assert!(EcdsaRecSignature::from_parts(alice_sig, 255, Compression::Compressed).is_err()); + } + + #[rstest] + #[case(0)] + #[case(1)] + #[case(2)] + #[case(3)] + fn recovery_id_roundtrip(#[case] id: u8, alice_sig: EcdsaSignature) { + let rec = EcdsaRecSignature::from_parts(alice_sig, id, Compression::Compressed).unwrap(); + assert_eq!(rec.recovery_id(), id); + } + + #[rstest] + fn normalize_s_flips_recovery_id(alice_rec_sig: EcdsaRecSignature) { + // Library signs with low-S, so normalize_s returns None. To test the flip + // we would need a high-S sig; verify the invariant instead: if normalize_s + // returns Some, the recovery_id must differ. + if let Some(normed) = alice_rec_sig.normalize_s() { + assert_ne!(normed.recovery_id(), alice_rec_sig.recovery_id()); + } + } + + #[rstest] + fn normalize_s_flips_high_s_recoverable_signature(alice_pk: EcdsaPublicKey, alice_rec_sig: EcdsaRecSignature) { + let compact = alice_rec_sig.to_compact(); + let mut high_bytes = [0u8; 64]; + high_bytes[..32].copy_from_slice(&compact[..32]); + high_bytes[32..].copy_from_slice(&negate_scalar(&compact[32..])); + let high_sig = EcdsaSignature::from_compact(&high_bytes).unwrap(); + + // The curve primitive rejects high-S signatures at recovery time (see + // `EcdsaSignature::verify`), so only the invariant that normalizing + // restores the original signature and recovery id is checked here. + let flipped_id = alice_rec_sig.recovery_id() ^ 1; + let high_rec = + EcdsaRecSignature::from_parts(high_sig, flipped_id, Compression::from(alice_rec_sig.is_compressed())).unwrap(); + + let normalized = high_rec.normalize_s().unwrap(); + assert_eq!(normalized.recovery_id(), alice_rec_sig.recovery_id()); + assert_eq!(normalized, alice_rec_sig); + assert_eq!(EcdsaPublicKey::recover(&MSG, &normalized).unwrap(), alice_pk); + } + + #[rstest] + fn verifies_without_downcast(alice_pk: EcdsaPublicKey, alice_rec_sig: EcdsaRecSignature) { + assert!(alice_pk.verify(&MSG, &alice_rec_sig).is_ok()); + assert!(alice_pk.verify(&MSG, alice_rec_sig.signature()).is_ok()); + } + + #[cfg(feature = "serde")] + #[rstest] + fn serde_roundtrip(alice_rec_sig: EcdsaRecSignature) { + assert_json_rt(&alice_rec_sig); + } +} diff --git a/pkgs/pkc/src/ecdsa/tests.rs b/pkgs/pkc/src/ecdsa/tests.rs index 4ba16b78..ec5206f0 100644 --- a/pkgs/pkc/src/ecdsa/tests.rs +++ b/pkgs/pkc/src/ecdsa/tests.rs @@ -6,7 +6,8 @@ //! Common test definitions. -use crate::ecdsa::{EcdsaPublicKey, EcdsaRecoveryId, EcdsaSecretKey, EcdsaSignature}; +use super::secret_ops::ORDER; +use crate::ecdsa::{Compression, EcdsaPublicKey, EcdsaRecSignature, EcdsaSecretKey, EcdsaSignature}; use hex_conservative::hex; use rstest::fixture; @@ -15,6 +16,20 @@ pub const ALICE_SK: [u8; 32] = hex!("0123456789abcdef0123456789abcdeffedcba98765 pub const BOB_SK: [u8; 32] = hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); pub const MSG: [u8; 32] = hex!("deadbeefdeadbeefdeadbeefdeadbeefcafebabecafebabecafebabecafebabe"); +/// Negate a scalar modulo the curve order (`order - s`), used to turn a low-S +/// signature into a high-S one for tests as the library itself only ever +/// produces low-S signatures. +pub(crate) fn negate_scalar(s: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + let mut borrow = 0i16; + for i in (0..32).rev() { + let diff = i16::from(ORDER[i]) - i16::from(s[i]) - borrow; + borrow = i16::from(diff < 0); + out[i] = diff.rem_euclid(256) as u8; + } + out +} + /// Derive a distinct 32-byte message digest from an index. pub fn message_hash(i: u16) -> [u8; 32] { let mut h = [0u8; 32]; @@ -30,16 +45,16 @@ pub fn alice_pk() -> EcdsaPublicKey { #[fixture] pub fn alice_sk() -> EcdsaSecretKey { - EcdsaSecretKey::from_bytes(&ALICE_SK).unwrap() + EcdsaSecretKey::from_bytes(&ALICE_SK, Compression::Compressed).unwrap() } #[fixture] pub fn bob_sk() -> EcdsaSecretKey { - EcdsaSecretKey::from_bytes(&BOB_SK).unwrap() + EcdsaSecretKey::from_bytes(&BOB_SK, Compression::Compressed).unwrap() } #[fixture] -pub fn alice_rec_sig() -> (EcdsaSignature, EcdsaRecoveryId) { +pub fn alice_rec_sig() -> EcdsaRecSignature { alice_sk().sign_recoverable(&MSG).unwrap() } diff --git a/pkgs/pkc/src/lib.rs b/pkgs/pkc/src/lib.rs index d65252e0..f3c145a2 100644 --- a/pkgs/pkc/src/lib.rs +++ b/pkgs/pkc/src/lib.rs @@ -20,6 +20,11 @@ pub mod ecdsa; #[cfg(feature = "std")] pub mod worker; +#[doc(hidden)] +pub mod __private { + pub use crate::ecdsa::PubKeyHash as __PubKeyHash; +} + cfg_if::cfg_if! { if #[cfg(feature = "bls")] { mod common; diff --git a/pkgs/pkc/src/prelude.rs b/pkgs/pkc/src/prelude.rs index 3e4af7bb..b0509665 100644 --- a/pkgs/pkc/src/prelude.rs +++ b/pkgs/pkc/src/prelude.rs @@ -8,4 +8,5 @@ pub(crate) use alloc::format; pub(crate) use alloc::string::String; +pub(crate) use alloc::vec; pub(crate) use alloc::vec::Vec; diff --git a/pkgs/primitives/Cargo.toml b/pkgs/primitives/Cargo.toml index a3c0ae8b..bf3ff333 100644 --- a/pkgs/primitives/Cargo.toml +++ b/pkgs/primitives/Cargo.toml @@ -7,9 +7,9 @@ license = "MIT" [features] default = [] std = [ - "base58ck/std", "bitcoin-consensus-encoding/std", "bitcoin-internals/std", + "bitcoin-primitives/std", "bitcoin_hashes/std", "bitcoin-units/std", "dash-num/std", @@ -21,6 +21,7 @@ std = [ ] serde = [ "dep:serde", + "bitcoin-primitives/serde", "bitcoin-units/serde", "dash-num/serde", "dash-pkc/serde", @@ -30,11 +31,13 @@ serde = [ full = ["std", "serde"] [dependencies] -base58ck = { version = "0.4", default-features = false, features = ["alloc"] } bitcoin-consensus-encoding = { version = "0.2", default-features = false, features = [ "alloc", ] } bitcoin-internals = { version = "0.5", default-features = false } +bitcoin-primitives = { version = "0.102", default-features = false, features = [ + "alloc", +] } bitcoin_hashes = { version = "0.20", default-features = false, features = [ "alloc", ] } @@ -45,7 +48,9 @@ dash-num = { version = "0.0.0", path = "../num" } dash-pkc = { version = "0.0.0", path = "../pkc", default-features = false } dash-pow = { version = "0.0.0", path = "../pow" } dash-script = { version = "0.0.0", path = "../script" } -dash-types = { version = "0.0.0", path = "../types", default-features = false } +dash-types = { version = "0.0.0", path = "../types", default-features = false, features = [ + "bitcoin-primitives", +] } cfg-if = "1" hex-conservative = { version = "0.3", default-features = false, features = ["alloc"] } libm = { version = "0.2", default-features = false } diff --git a/pkgs/primitives/src/lib.rs b/pkgs/primitives/src/lib.rs index ff537c57..e49508ff 100644 --- a/pkgs/primitives/src/lib.rs +++ b/pkgs/primitives/src/lib.rs @@ -18,7 +18,6 @@ mod gov; mod payload; #[allow(unused_imports, reason = "ergonomic shim, exports may be unused")] mod prelude; -mod script; mod support; mod transaction; mod types; @@ -45,7 +44,6 @@ pub use payload::{ PlatformNodeId, ProRegTx, ProTxInvalid, ProUpRegTx, ProUpRevTx, ProUpServTx, QuorumHash, QuorumVvecHash, SpecialPayload, TxType, VERSIONBITS_NUM_BITS, }; -pub use script::{KeyId, Script}; pub use support::{DynBitset, DynBitsetIterator, LlmqType, RevocationReason}; pub use transaction::{ OutPoint, Transaction, TxHash, TxIn, TxInvalid, TxOut, MAX_COINBASE_SCRIPT_SIZE, MAX_TX_EXTRA_PAYLOAD, diff --git a/pkgs/primitives/src/payload/proregtx.rs b/pkgs/primitives/src/payload/proregtx.rs index eed64b6b..dba4bc59 100644 --- a/pkgs/primitives/src/payload/proregtx.rs +++ b/pkgs/primitives/src/payload/proregtx.rs @@ -12,11 +12,12 @@ use super::{ }; use crate::codec::impl_payload; use crate::prelude::*; -use crate::script::{KeyId, Script}; use crate::types::{NITrait, NetInfo, NetInfoV1, NetInfoV2, ServiceV1}; use crate::{hash_impl, TxHash}; +use bitcoin_primitives::script::ScriptPubKeyBuf; use dash_pkc::bls::{BlsPkBytes, BlsScIetf}; +use dash_script::PubKeyHash; use dash_types::codec::{BaseCodec, Checkable, DecodeError, EncodeBuf, NumCodec}; use dash_types::{make_bytes, TypeId}; @@ -44,15 +45,15 @@ pub struct ProRegTx { /// Legacy ServiceV1 or extended NetInfo. pub net_info: NetInfo, /// Owner key id (20 bytes). - pub key_id_owner: KeyId, + pub key_id_owner: PubKeyHash, /// Operator BLS public key (48 bytes). pub pub_key_operator: BlsPkBytes, /// Voting key id (20 bytes). - pub key_id_voting: KeyId, + pub key_id_voting: PubKeyHash, /// Operator reward in basis points (0-10000). pub operator_reward: u16, /// Payout script. - pub script_payout: Script, + pub script_payout: ScriptPubKeyBuf, /// Hash of all inputs. pub inputs_hash: InputsHash, /// Platform node id (Evo only). @@ -107,11 +108,11 @@ impl BaseCodec for ProRegTx { } else { NetInfo::Legacy(NetInfoV1(ServiceV1::decode(data)?)) }; - let key_id_owner = KeyId::decode(data)?; + let key_id_owner = PubKeyHash::decode(data)?; let pub_key_operator = BlsPkBytes::::decode(data)?; - let key_id_voting = KeyId::decode(data)?; + let key_id_voting = PubKeyHash::decode(data)?; let operator_reward = u16::decode(data)?; - let script_payout = Script::decode(data)?; + let script_payout = ScriptPubKeyBuf::decode(data)?; let inputs_hash = InputsHash::decode(data)?; let (platform_node_id, platform_p2p_port, platform_http_port) = if mn_type == MnType::Evo { let node_id = PlatformNodeId::decode(data)?; diff --git a/pkgs/primitives/src/payload/proupregtx.rs b/pkgs/primitives/src/payload/proupregtx.rs index 98fc5943..50c41d24 100644 --- a/pkgs/primitives/src/payload/proupregtx.rs +++ b/pkgs/primitives/src/payload/proupregtx.rs @@ -9,10 +9,11 @@ use super::{InputsHash, ProTxInvalid}; use crate::codec::codec_payload; use crate::prelude::*; -use crate::script::{KeyId, Script}; use crate::TxHash; +use bitcoin_primitives::script::ScriptPubKeyBuf; use dash_pkc::bls::{BlsPkBytes, BlsScIetf}; +use dash_script::PubKeyHash; use dash_types::codec::Checkable; use dash_types::TypeId; @@ -35,9 +36,9 @@ pub struct ProUpRegTx { /// Operator BLS public key (48 bytes). pub pub_key_operator: BlsPkBytes, /// Voting key id (20 bytes). - pub key_id_voting: KeyId, + pub key_id_voting: PubKeyHash, /// Payout script. - pub script_payout: Script, + pub script_payout: ScriptPubKeyBuf, /// Hash of all inputs. pub inputs_hash: InputsHash, /// Owner ECDSA signature (variable-length). diff --git a/pkgs/primitives/src/payload/proupservtx.rs b/pkgs/primitives/src/payload/proupservtx.rs index 105e351f..60c669f4 100644 --- a/pkgs/primitives/src/payload/proupservtx.rs +++ b/pkgs/primitives/src/payload/proupservtx.rs @@ -9,10 +9,10 @@ use super::proregtx::{check_platform_fields, PlatformNodeId}; use super::{check_sptx_netinfo, InputsHash, MnType, ProTxInvalid, PROTX_VERSION_BASIC_BLS, PROTX_VERSION_EXT_ADDR}; use crate::codec::impl_payload; -use crate::script::Script; use crate::types::{NITrait, NetInfo, NetInfoV1, NetInfoV2, ServiceV1}; use crate::{hash_impl, TxHash}; +use bitcoin_primitives::script::ScriptPubKeyBuf; use dash_pkc::bls::{BlsScIetf, BlsSigBytes}; use dash_types::codec::{BaseCodec, Checkable, DecodeError, EncodeBuf, NumCodec}; use dash_types::TypeId; @@ -37,7 +37,7 @@ pub struct ProUpServTx { /// Legacy ServiceV1 or extended NetInfo. pub net_info: NetInfo, /// Operator payout script. - pub script_operator_payout: Script, + pub script_operator_payout: ScriptPubKeyBuf, /// Hash of all inputs. pub inputs_hash: InputsHash, /// Platform node id (Evo only). @@ -70,7 +70,7 @@ impl BaseCodec for ProUpServTx { } else { NetInfo::Legacy(NetInfoV1(ServiceV1::decode(data)?)) }; - let script_operator_payout = Script::decode(data)?; + let script_operator_payout = ScriptPubKeyBuf::decode(data)?; let inputs_hash = InputsHash::decode(data)?; let (platform_node_id, platform_p2p_port, platform_http_port) = if mn_type == MnType::Evo { let node_id = PlatformNodeId::decode(data)?; diff --git a/pkgs/primitives/src/script.rs b/pkgs/primitives/src/script.rs deleted file mode 100644 index cfd76e0b..00000000 --- a/pkgs/primitives/src/script.rs +++ /dev/null @@ -1,93 +0,0 @@ -// -// Copyright (c) 2026-present, The Dash Core developers -// SPDX-License-Identifier: MIT -// See the accompanying file LICENSE or https://opensource.org/license/MIT -// - -//! Variable-length script with CompactSize-prefixed consensus encoding. - -use crate::hash_impl; -use crate::prelude::*; - -use dash_types::codec::{ArrayBuf, BaseCodec, DecodeError, EncodeBuf}; -use dash_types::{impl_type, make_bytes, TypeId}; - -use core::fmt; - -/// A variable-length script, CompactSize-prefixed on the wire. -#[derive(Clone, Eq, Hash, PartialEq, Default, TypeId)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] -pub struct Script(#[cfg_attr(feature = "serde", serde(with = "dash_types::serialize::hex"))] pub Vec); - -impl_type!(Script); - -impl BaseCodec for Script { - fn decode(data: &mut &[u8]) -> Result { - Vec::decode(data).map(Self) - } - - fn encode(&self, buf: &mut impl EncodeBuf) { - self.0.encode(buf); - } -} - -hash_impl!(Script); - -impl Script { - /// Creates a new script from raw bytes. - pub fn new(data: Vec) -> Self { - Self(data) - } - - /// Returns a reference to the script bytes. - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - - /// Returns the length in bytes. - pub fn len(&self) -> usize { - self.0.len() - } - - /// Returns whether the script is empty. - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl fmt::Debug for Script { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Script(")?; - for byte in &self.0 { - write!(f, "{:02x}", byte)?; - } - write!(f, ")") - } -} - -impl fmt::Display for Script { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for byte in &self.0 { - write!(f, "{:02x}", byte)?; - } - Ok(()) - } -} - -make_bytes! { - /// 20-byte public key hash (RIPEMD-160 of SHA-256). - KeyId, 20 -} - -hash_impl!(KeyId); - -impl KeyId { - /// Encode as a Base58Check string with the given version prefix. - pub fn to_base58c(&self, prefix: u8) -> String { - let mut buf = ArrayBuf::<21>::new(); - buf.push(prefix); - self.encode(&mut buf); - base58ck::encode_check(&buf.into_array()) - } -} diff --git a/pkgs/primitives/src/transaction.rs b/pkgs/primitives/src/transaction.rs index a914e613..9f6933fd 100644 --- a/pkgs/primitives/src/transaction.rs +++ b/pkgs/primitives/src/transaction.rs @@ -9,10 +9,10 @@ use crate::payload::{PayloadError, PayloadInvalid, TxType}; use crate::prelude::*; -use crate::script::Script; use crate::{codec_type, hash_impl}; use bitcoin_hashes::sha256d; +use bitcoin_primitives::script::{ScriptPubKeyBuf, ScriptSigBuf}; use bitcoin_units::Amount; use dash_num::{make_hash, Hash256}; use dash_types::codec::{self, BaseCodec, Checkable, DecodeError, EncodeBuf, Hashable, NumCodec}; @@ -68,7 +68,7 @@ pub struct TxIn { /// The outpoint being spent. pub prevout: OutPoint, /// Unlocking script. - pub script_sig: Script, + pub script_sig: ScriptSigBuf, /// Sequence number. pub sequence: u32, } @@ -95,7 +95,7 @@ pub struct TxOut { pub value: Amount, /// Locking script. #[cfg_attr(feature = "serde", serde(rename = "scriptPubKey"))] - pub script_pubkey: Script, + pub script_pubkey: ScriptPubKeyBuf, } impl_type!(TxOut); @@ -109,7 +109,7 @@ impl BaseCodec for TxOut { })?; Ok(Self { value, - script_pubkey: Script::decode(data)?, + script_pubkey: ScriptPubKeyBuf::decode(data)?, }) } diff --git a/pkgs/script/Cargo.toml b/pkgs/script/Cargo.toml index 15f6c5d0..1c54e60f 100644 --- a/pkgs/script/Cargo.toml +++ b/pkgs/script/Cargo.toml @@ -10,10 +10,11 @@ std = [ "bitcoin_hashes/std", "base58ck/std", "bitcoin-consensus-encoding/std", + "dash-pkc/std", "dash-types/std", ] full = ["std", "serde"] -serde = ["dep:serde"] +serde = ["dep:serde", "dash-pkc/serde", "dash-types/serde"] [dependencies] base58ck = { version = "0.4", default-features = false, features = ["alloc"] } @@ -23,7 +24,10 @@ bitcoin-consensus-encoding = { version = "0.2", default-features = false, featur bitcoin_hashes = { version = "0.20", default-features = false, features = [ "alloc", ] } -dash-types = { version = "0.0.0", path = "../types", default-features = false } +dash-pkc = { version = "0.0.0", path = "../pkc", default-features = false } +dash-types = { version = "0.0.0", path = "../types", default-features = false, features = [ + "bitcoin-primitives", +] } serde = { version = "1", default-features = false, features = [ "derive", "alloc", diff --git a/pkgs/script/src/addrs.rs b/pkgs/script/src/addrs.rs new file mode 100644 index 00000000..b13e31b9 --- /dev/null +++ b/pkgs/script/src/addrs.rs @@ -0,0 +1,26 @@ +// +// Copyright (c) 2026-present, The Dash Core developers +// SPDX-License-Identifier: MIT +// See the accompanying file LICENSE or https://opensource.org/license/MIT +// + +//! Address definitions and network parameters. + +use dash_types::Unencodable; + +/// Network address encoding parameters. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Unencodable)] +pub struct AddrParams { + /// P2PKH address version byte. + pub pubkey_addr: u8, + /// P2SH address version byte. + pub script_addr: u8, + /// WIF private key version byte. + pub secret_key: u8, + /// BIP32 extended public key prefix. + pub ext_pubkey: [u8; 4], + /// BIP32 extended secret key prefix. + pub ext_secret: [u8; 4], + /// BIP44 coin type index. + pub bip44_idx: u32, +} diff --git a/pkgs/script/src/lib.rs b/pkgs/script/src/lib.rs index b4c50387..48a2e4cf 100644 --- a/pkgs/script/src/lib.rs +++ b/pkgs/script/src/lib.rs @@ -12,6 +12,7 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; +mod addrs; #[allow(unused_imports, reason = "ergonomic shim, exports may be unused")] mod prelude; @@ -24,6 +25,9 @@ use dash_types::Unencodable; pub mod opcode; +pub use addrs::AddrParams; +pub use dash_pkc::__private::__PubKeyHash as PubKeyHash; +pub use dash_types::__private::__ScriptHash as ScriptHash; pub use opcode::Opcode; /// RIPEMD-160(SHA-256) output length in bytes. diff --git a/pkgs/types/Cargo.toml b/pkgs/types/Cargo.toml index 73c4acad..67cea7e4 100644 --- a/pkgs/types/Cargo.toml +++ b/pkgs/types/Cargo.toml @@ -6,12 +6,30 @@ license = "MIT" [features] default = [] -std = ["bitcoin-consensus-encoding/std", "hex-conservative?/std"] -full = ["std", "serde"] +std = [ + "bitcoin-consensus-encoding/std", + "bitcoin-primitives?/std", + "hex-conservative?/std", +] +full = ["std", "serde", "bitcoin-primitives"] +bitcoin-primitives = [ + "dep:base58ck", + "dep:bitcoin-primitives", + "dep:bitcoin_hashes", +] serde = ["dep:serde", "dep:hex-conservative"] [dependencies] +base58ck = { version = "0.4", default-features = false, optional = true, features = [ + "alloc", +] } bitcoin-consensus-encoding = { version = "0.2", default-features = false } +bitcoin_hashes = { version = "0.20", default-features = false, optional = true, features = [ + "alloc", +] } +bitcoin-primitives = { version = "0.102", default-features = false, optional = true, features = [ + "alloc", +] } cfg-if = "1" dash-types-marker = { version = "0.0.0", path = "marker" } hex-conservative = { version = "0.3", default-features = false, features = [ diff --git a/pkgs/types/src/adapters.rs b/pkgs/types/src/adapters.rs new file mode 100644 index 00000000..42c3028b --- /dev/null +++ b/pkgs/types/src/adapters.rs @@ -0,0 +1,104 @@ +// +// Copyright (c) 2026-present, The Dash Core developers +// SPDX-License-Identifier: MIT +// See the accompanying file LICENSE or https://opensource.org/license/MIT +// + +//! Bridging modules for foreign crate types. + +/// Bridges an upstream type into [`BaseCodec`]. +macro_rules! adapt_codec { + (<$gen:ident>, $ty:ty) => { + impl<$gen> $crate::codec::BaseCodec for $ty { + fn decode(data: &mut &[u8]) -> Result { + let n = $crate::codec::read_compact_size(data, data.len())?; + let bytes = $crate::codec::read_bytes(data, n)?; + Ok(Self::from_bytes(bytes.to_vec())) + } + + fn encode(&self, buf: &mut impl $crate::codec::EncodeBuf) { + let bytes = self.as_bytes(); + $crate::codec::write_compact_size(bytes.len(), buf); + buf.extend_from_slice(bytes); + } + } + }; + ($ty:ty, $len:expr) => { + impl $crate::codec::BaseCodec for $ty { + fn decode(data: &mut &[u8]) -> Result { + let bytes = $crate::codec::read_bytes(data, $len)?; + let mut arr = [0u8; $len]; + arr.copy_from_slice(bytes); + Ok(Self::from_byte_array(arr)) + } + + fn encode(&self, buf: &mut impl $crate::codec::EncodeBuf) { + buf.extend_from_slice(&self.to_byte_array()); + } + } + }; +} + +#[cfg(feature = "bitcoin-primitives")] +pub mod bitcoin_primitives { + use crate::codec::{ArrayBuf, BaseCodec, EncodeBuf, Hashable}; + use crate::make_bytes; + use crate::prelude::*; + + use base58ck::encode_check; + use bitcoin_hashes::{ripemd160, sha256}; + use bitcoin_primitives::script::{ScriptBuf, ScriptHashableTag}; + + adapt_codec!(, ScriptBuf); + + // nosemgrep: types-macro-no-codec + make_bytes! { + /// 20-byte script hash. + ScriptHash, 20 + } + + impl ScriptHash { + /// Encode as a Base58Check address with the given version prefix. + pub fn to_base58c(&self, prefix: u8) -> String { + let mut buf = ArrayBuf::<21>::new(); + buf.push(prefix); + self.encode(&mut buf); + encode_check(&buf.into_array()) + } + } + + impl Hashable for ScriptBuf { + type Hash = ScriptHash; + + fn hash(&self) -> ScriptHash { + ScriptHash::from(*ripemd160::Hash::hash(sha256::Hash::hash(self.as_bytes()).as_ref()).as_byte_array()) + } + } + + #[cfg(test)] + #[expect(clippy::unwrap_used, reason = "test code")] + mod tests { + use crate::codec::BaseCodec; + use crate::prelude::*; + + use bitcoin_primitives::script::{ScriptBuf, ScriptPubKeyTag}; + use rstest::*; + + #[rstest] + fn codec_roundtrip() { + let script = ScriptBuf::::from_bytes(alloc::vec![0x76, 0xa9, 0x14, 0xff]); + let mut buf = Vec::new(); + script.encode(&mut buf); + let decoded = ScriptBuf::::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(decoded.as_bytes(), script.as_bytes()); + } + + #[rstest] + fn codec_is_length_prefixed() { + let script = ScriptBuf::::from_bytes(alloc::vec![0x51, 0x52]); + let mut buf = Vec::new(); + script.encode(&mut buf); + assert_eq!(buf, alloc::vec![2, 0x51, 0x52]); + } + } +} diff --git a/pkgs/types/src/lib.rs b/pkgs/types/src/lib.rs index d2d4cf81..2693b6a4 100644 --- a/pkgs/types/src/lib.rs +++ b/pkgs/types/src/lib.rs @@ -13,6 +13,8 @@ extern crate self as dash_types; #[cfg(feature = "std")] extern crate std; +#[allow(unused_macros, reason = "used by feature-gated submodules")] +mod adapters; mod entity; mod hex; mod macros; @@ -29,6 +31,9 @@ pub use entity::{ArrDecoder, ArrEncoder, BufferDecoder, VecEncoder, MAX_ARR_SIZE #[doc(hidden)] pub mod __private { + #[cfg(feature = "bitcoin-primitives")] + pub use crate::adapters::bitcoin_primitives::ScriptHash as __ScriptHash; + pub use bitcoin_consensus_encoding; #[cfg(feature = "serde")] pub use hex_conservative; diff --git a/pkgs/types/src/macros.rs b/pkgs/types/src/macros.rs index de38328c..bf6c4dfc 100644 --- a/pkgs/types/src/macros.rs +++ b/pkgs/types/src/macros.rs @@ -258,32 +258,75 @@ macro_rules! enum_map { /// body receives `&$src`; the owned impl delegates. #[macro_export] macro_rules! type_cvrt { - (From<$src:ty> for $dst:ty, |$v:ident| $body:expr) => { - impl core::convert::From<&$src> for $dst { + (@parse [$($impl_generics:tt)*] From<$src:ty> for $dst:ty, |$v:ident| $body:expr) => { + impl $($impl_generics)* core::convert::From<&$src> for $dst { fn from($v: &$src) -> Self { $body } } - impl core::convert::From<$src> for $dst { + impl $($impl_generics)* core::convert::From<$src> for $dst { fn from(v: $src) -> Self { Self::from(&v) } } }; - (TryFrom<$src:ty> for $dst:ty, $err:ty, |$v:ident| $body:expr) => { - impl core::convert::TryFrom<&$src> for $dst { + (@parse [$($impl_generics:tt)*] TryFrom<$src:ty> for $dst:ty, $err:ty, |$v:ident| $body:expr) => { + impl $($impl_generics)* core::convert::TryFrom<&$src> for $dst { type Error = $err; fn try_from($v: &$src) -> Result { $body } } - impl core::convert::TryFrom<$src> for $dst { + impl $($impl_generics)* core::convert::TryFrom<$src> for $dst { type Error = $err; fn try_from(v: $src) -> Result { Self::try_from(&v) } } }; + ($($args:tt)*) => { + $crate::type_cvrt!(@parse [] $($args)*); + }; +} + +/// Delegates `BaseCodec`, `Hashable`, and `impl_type!` through another type. +/// +/// Decoding is fallible (`$bytes` is unvalidated, so `TryFrom` guards the +/// operational type), encoding is not: the operational type is already valid, +/// so `From<&$ops> for $bytes` must exist. +/// +/// An encode direction that could fail would have to either emit nothing or +/// hash a placeholder, both of which silently corrupt the wire image. +/// +/// `$max` bounds the `impl_type!` decoder buffer to the wrapped type's own +/// maximum encoded length. +#[macro_export] +macro_rules! dlgt_codec { + (@parse [$($impl_generics:tt)*] $ops:ty => $bytes:ty, $hash:ty, $err:ty, $max:expr) => { + impl $($impl_generics)* $crate::codec::BaseCodec<$err> for $ops { + fn decode(data: &mut &[u8]) -> Result> { + let inner = <$bytes as $crate::codec::BaseCodec>::decode(data).map_err(|e| e.lift())?; + Self::try_from(inner).map_err($crate::codec::DecodeError::DecError) + } + + fn encode(&self, buf: &mut impl $crate::codec::EncodeBuf) { + <$bytes as core::convert::From<&Self>>::from(self).encode(buf); + } + } + + impl $($impl_generics)* $crate::codec::Hashable for $ops { + type Hash = $hash; + + fn hash(&self) -> $hash { + $crate::codec::Hashable::hash(&<$bytes as core::convert::From<&Self>>::from(self)) + } + } + + $crate::impl_type!(@parse [$($impl_generics)*] $ops, $max, $err); + }; + ($($args:tt)*) => { + $crate::dlgt_codec!(@parse [] $($args)*); + }; } #[cfg(test)]