diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,12 @@ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" + [[package]] name = "byteorder" version = "1.4.3" @@ -535,6 +541,18 @@ "crypto-common", ] +[[package]] +name = "ecash-lib-wasm" +version = "0.1.0" +dependencies = [ + "abc-rust-lint", + "ripemd", + "secp256k1-abc", + "sha2", + "thiserror", + "wasm-bindgen", +] + [[package]] name = "either" version = "1.9.0" @@ -1589,6 +1607,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secp256k1-abc" +version = "0.20.3" +source = "git+https://github.com/raipay/secp256k1-abc?rev=b23e742#b23e74219f5c425eada0f53a52f9b51fdb3b23b2" +dependencies = [ + "secp256k1-sys-abc", +] + +[[package]] +name = "secp256k1-sys-abc" +version = "0.4.1" +source = "git+https://github.com/raipay/secp256k1-abc?rev=b23e742#b23e74219f5c425eada0f53a52f9b51fdb3b23b2" +dependencies = [ + "cc", +] + [[package]] name = "semver" version = "1.0.18" @@ -2030,6 +2064,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.29", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + [[package]] name = "which" version = "4.4.0" diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,13 @@ "chronik/chronik-plugin", "chronik/chronik-proto", "chronik/chronik-util", + "modules/ecash-lib-wasm", ] [workspace.package] rust-version = "1.76.0" + +[profile.release-wasm] +inherits = "release" +lto = true +opt-level = "z" diff --git a/contrib/teamcity/build-configurations.yml b/contrib/teamcity/build-configurations.yml --- a/contrib/teamcity/build-configurations.yml +++ b/contrib/teamcity/build-configurations.yml @@ -63,6 +63,8 @@ # relative to the repository top level (no trailing /). # - DEPENDS_MOCK_CHRONIK_CLIENT: "true" if these tests require # the mock-chronik-client library, otherwise unset + # - DEPENDS_ECASH_LIB_WASM: "true" if these tests require ecash-lib-wasm + # to be built, otherwise unset js-mocha: artifacts: coverage.tar.gz: coverage.tar.gz @@ -86,6 +88,15 @@ npm ci fi + # Build ecash-lib-wasm for ecash-lib's WebAssembly part + if [ -z "${DEPENDS_ECASH_LIB_WASM+x}" ] ; then + echo "Test does not depend on ecash-lib-wasm, skipping" + else + echo "Test depends on ecash-lib-wasm. Building WebAssembly..." + pushd "${TOPLEVEL}/modules/ecash-lib-wasm" + ./build-wasm.sh + fi + pushd "${TOPLEVEL}/${JS_PROJECT_ROOT}" MOCHA_JUNIT_DIR="test_results" @@ -824,6 +835,7 @@ env: JS_PROJECT_ROOT: modules/ecash-lib RUN_NPM_BUILD: "true" + DEPENDS_ECASH_LIB_WASM: "true" templates: - js-mocha diff --git a/contrib/utils/install-dependencies-bullseye.sh b/contrib/utils/install-dependencies-bullseye.sh --- a/contrib/utils/install-dependencies-bullseye.sh +++ b/contrib/utils/install-dependencies-bullseye.sh @@ -14,6 +14,7 @@ bison bsdmainutils build-essential + binaryen ccache cmake curl @@ -185,7 +186,11 @@ "aarch64-unknown-linux-gnu" \ "arm-unknown-linux-gnueabihf" \ "x86_64-apple-darwin" \ - "x86_64-pc-windows-gnu" + "x86_64-pc-windows-gnu" \ + "wasm32-unknown-unknown" + +# Install wasm-bindgen to extract type info from .wasm files +"${RUST_HOME}/cargo" install -f wasm-bindgen-cli@0.2.92 # Install Electrum ABC test dependencies here=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")") diff --git a/modules/ecash-lib-wasm/.gitignore b/modules/ecash-lib-wasm/.gitignore new file mode 100644 --- /dev/null +++ b/modules/ecash-lib-wasm/.gitignore @@ -0,0 +1 @@ +/target diff --git a/modules/ecash-lib-wasm/Cargo.toml b/modules/ecash-lib-wasm/Cargo.toml new file mode 100644 --- /dev/null +++ b/modules/ecash-lib-wasm/Cargo.toml @@ -0,0 +1,34 @@ +# Copyright (c) 2024 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +[package] +name = "ecash-lib-wasm" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +abc-rust-lint = { path = "../../chronik/abc-rust-lint" } + +# Generate bindings from WebAssembly <-> JS +wasm-bindgen = "=0.2.92" + +# Derive error structs/enums +thiserror = "1.0" + +# Implementation of RIPEMD-160 etc. cryptographic hash functions +ripemd = "0.1" + +# Implementation of SHA-256 etc. cryptographic hash functions +sha2 = "0.10" + +[dependencies.secp256k1-abc] +# libsecp256k1 with support for BCH/XEC/XPI Schnorr signatures +git = "https://github.com/raipay/secp256k1-abc" +rev = "b23e742" +default-features = false +features = ["alloc"] diff --git a/modules/ecash-lib-wasm/Dockerfile b/modules/ecash-lib-wasm/Dockerfile new file mode 100644 --- /dev/null +++ b/modules/ecash-lib-wasm/Dockerfile @@ -0,0 +1,10 @@ +# Copyright (c) 2024 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +FROM rust:1.76.0 + +RUN apt-get update \ + && apt-get install clang binaryen -y \ + && rustup target add wasm32-unknown-unknown \ + && cargo install -f wasm-bindgen-cli@0.2.92 diff --git a/modules/ecash-lib-wasm/build-wasm.sh b/modules/ecash-lib-wasm/build-wasm.sh new file mode 100755 --- /dev/null +++ b/modules/ecash-lib-wasm/build-wasm.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +export LC_ALL=C.UTF-8 + +# Build wasm +CARGO_TARGET_DIR=./target \ +CFLAGS="-Wno-pointer-sign -Wno-implicit-function-declaration" \ +RUSTFLAGS="-C strip=debuginfo" \ + cargo build \ + --profile=release-wasm \ + --target=wasm32-unknown-unknown + +# cargo builds our wasm file here +WASM_FILE=./target/wasm32-unknown-unknown/release-wasm/ecash_lib_wasm.wasm + +# Optimize wasm for compact size +wasm-opt -Oz $WASM_FILE -o $WASM_FILE + +# Generate JS/TS bindings for the wasm file +wasm-bindgen $WASM_FILE --out-dir ../ecash-lib/src/ffi --target web diff --git a/modules/ecash-lib-wasm/dockerbuild.sh b/modules/ecash-lib-wasm/dockerbuild.sh new file mode 100755 --- /dev/null +++ b/modules/ecash-lib-wasm/dockerbuild.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +export LC_ALL=C.UTF-8 + +docker build . -t ecash-lib-build-wasm + +docker run \ + -v "$(pwd)/../../:/bitcoin-abc" \ + -w /bitcoin-abc/modules/ecash-lib-wasm \ + ecash-lib-build-wasm \ + ./build-wasm.sh diff --git a/modules/ecash-lib-wasm/src/ecc.rs b/modules/ecash-lib-wasm/src/ecc.rs new file mode 100644 --- /dev/null +++ b/modules/ecash-lib-wasm/src/ecc.rs @@ -0,0 +1,101 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! Module for [`Ecc`] for signing secp256k1 signatures. + +use secp256k1_abc::{ + constants::SECRET_KEY_SIZE, All, Message, PublicKey, Secp256k1, SecretKey, +}; +use thiserror::Error; +use wasm_bindgen::prelude::*; + +use crate::ecc::EccError::*; + +/// ECC signatures with libsecp256k1. +#[derive(Debug)] +#[wasm_bindgen] +pub struct Ecc { + curve: Secp256k1, +} + +/// Errors indicating something went wrong with [`Ecc`]. +#[derive(Error, Debug, PartialEq, Eq)] +pub enum EccError { + /// Invalid secret key size + #[error("Invalid secret key size, expected 32 bytes but got {0}")] + InvalidSeckeySize(usize), + + /// Invalid secret key for signing + #[error("Secret key not valid for secp256k1")] + InvalidSeckey, + + /// Invalid message size + #[error("Invalid message size, expected 32 bytes but got {0}")] + InvalidMessageSize(usize), +} + +impl From for String { + fn from(ecc: EccError) -> Self { + ecc.to_string() + } +} + +fn parse_secret_key(seckey: &[u8]) -> Result { + Ok(SecretKey::from_slice(seckey).map_err(|_| { + if seckey.len() != SECRET_KEY_SIZE { + return InvalidSeckeySize(seckey.len()); + } + InvalidSeckey + })?) +} + +fn parse_msg(msg: &[u8]) -> Result { + Ok(Message::from_slice(msg).map_err(|_| InvalidMessageSize(msg.len()))?) +} + +#[wasm_bindgen] +impl Ecc { + /// Create a new Ecc instance. + #[allow(clippy::new_without_default)] + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Ecc { + curve: Secp256k1::default(), + } + } + + /// Derive a public key from secret key. + #[wasm_bindgen(js_name = derivePubkey)] + pub fn derive_pubkey(&self, seckey: &[u8]) -> Result, String> { + let seckey = parse_secret_key(seckey)?; + let pubkey = PublicKey::from_secret_key(&self.curve, &seckey); + Ok(pubkey.serialize().to_vec()) + } + + /// Sign an ECDSA signature. + #[wasm_bindgen(js_name = ecdsaSign)] + pub fn ecdsa_sign( + &self, + seckey: &[u8], + msg: &[u8], + ) -> Result, String> { + let seckey = parse_secret_key(seckey)?; + let msg = parse_msg(msg)?; + let sig = self.curve.sign(&msg, &seckey); + Ok(sig.serialize_der().to_vec()) + } + + /// Sign a Schnorr signature. + #[wasm_bindgen(js_name = schnorrSign)] + pub fn schnorr_sign( + &self, + seckey: &[u8], + msg: &[u8], + ) -> Result, String> { + let seckey = parse_secret_key(seckey)?; + let msg = parse_msg(msg)?; + let sig = self.curve.schnorrabc_sign_no_aux_rand(&msg, &seckey); + Ok(sig.as_ref().to_vec()) + } +} diff --git a/modules/ecash-lib-wasm/src/hash.rs b/modules/ecash-lib-wasm/src/hash.rs new file mode 100644 --- /dev/null +++ b/modules/ecash-lib-wasm/src/hash.rs @@ -0,0 +1,26 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! Module for cryptographic hashes + +use sha2::Digest; +use wasm_bindgen::prelude::wasm_bindgen; + +/// Calculate RIPEMD160(SHA256(data)), commonly used as address hash. +#[wasm_bindgen(js_name = shaRmd160)] +pub fn sha_rmd160(data: &[u8]) -> Vec { + ripemd::Ripemd160::digest(sha2::Sha256::digest(data)).to_vec() +} + +/// Calculate SHA256(data). +#[wasm_bindgen] +pub fn sha256(data: &[u8]) -> Vec { + sha2::Sha256::digest(data).to_vec() +} + +/// Calculate SHA256(SHA256(data)). +#[wasm_bindgen] +pub fn sha256d(data: &[u8]) -> Vec { + sha2::Sha256::digest(sha2::Sha256::digest(data)).to_vec() +} diff --git a/modules/ecash-lib-wasm/src/lib.rs b/modules/ecash-lib-wasm/src/lib.rs new file mode 100644 --- /dev/null +++ b/modules/ecash-lib-wasm/src/lib.rs @@ -0,0 +1,10 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! WebAssembly module for ECC and cryptographic hashes. + +abc_rust_lint::lint! { + pub mod ecc; + pub mod hash; +} diff --git a/modules/ecash-lib/.gitignore b/modules/ecash-lib/.gitignore --- a/modules/ecash-lib/.gitignore +++ b/modules/ecash-lib/.gitignore @@ -12,3 +12,6 @@ # compiled ts dist/ + +# compiled WebAssembly +src/ffi/ diff --git a/modules/ecash-lib/.npmignore b/modules/ecash-lib/.npmignore --- a/modules/ecash-lib/.npmignore +++ b/modules/ecash-lib/.npmignore @@ -1 +1,8 @@ +# Included in sourcemaps already /src + +# Don't publish tests +*.test.d.ts +*.test.d.ts.map +*.test.js +*.test.js.map diff --git a/modules/ecash-lib/package.json b/modules/ecash-lib/package.json --- a/modules/ecash-lib/package.json +++ b/modules/ecash-lib/package.json @@ -5,7 +5,7 @@ "main": "./dist/index.js", "type": "module", "scripts": { - "build": "tsc", + "build": "tsc && cp -r ./src/ffi ./dist", "test": "mocha --import=tsx ./src/*.test.ts ./src/**/*.test.ts", "coverage": "nyc npm run test", "junit": "npm run test --reporter mocha-junit-reporter" @@ -30,6 +30,7 @@ "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/chai": "^4.3.14", "@types/mocha": "^10.0.6", + "@types/node": "^20.12.7", "chai": "^5.1.0", "eslint-plugin-header": "^3.1.1", "mocha": "^10.4.0", diff --git a/modules/ecash-lib/src/ecc.test.ts b/modules/ecash-lib/src/ecc.test.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/ecc.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { expect } from 'chai'; +import fs from 'node:fs/promises'; + +import { fromHex, toHex } from './io/hex.js'; +import { EccDummy, EccWasm, initWasm } from './ecc.js'; + +describe('Ecc', async () => { + // Can't use `fetch` for local file so we have to read it using `fs` + await initWasm(fs.readFile('./src/ffi/ecash_lib_wasm_bg.wasm')); + + it('EccWasm', () => { + const ecc = new EccWasm(); + const sk = fromHex( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ); + expect(toHex(ecc.derivePubkey(sk))).to.equal( + '034646ae5047316b4230d0086c8acec687f00b1cd9d1dc634f6cb358ac0a9a8fff', + ); + expect(ecc.schnorrSign(sk, new Uint8Array(32))).to.have.lengthOf(64); + expect(ecc.ecdsaSign(sk, new Uint8Array(32))).length.to.be.within( + 65, + 73, + ); + }); + + it('EccDummy', () => { + const dummy = new EccDummy(); + expect(dummy.derivePubkey({} as any)).to.deep.equal(new Uint8Array(33)); + expect(dummy.schnorrSign({} as any, {} as any)).to.deep.equal( + new Uint8Array(64), + ); + expect(dummy.ecdsaSign({} as any, {} as any)).to.deep.equal( + new Uint8Array(73), + ); + }); +}); diff --git a/modules/ecash-lib/src/ecc.ts b/modules/ecash-lib/src/ecc.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/ecc.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +// These files are generated in "ecash-lib-wasm" via build-wasm.sh or +// dockerbuild.sh. +import * as ffi from './ffi/ecash_lib_wasm.js'; +import __wbg_init from './ffi/ecash_lib_wasm.js'; + +/** Interface to abstract over Elliptic Curve Cryptography */ +export interface Ecc { + /** Derive a public key from secret key. */ + derivePubkey(seckey: Uint8Array): Uint8Array; + + /** Sign an ECDSA signature. msg needs to be a 32-byte hash */ + ecdsaSign(seckey: Uint8Array, msg: Uint8Array): Uint8Array; + + /** Sign a Schnorr signature. msg needs to be a 32-byte hash */ + schnorrSign(seckey: Uint8Array, msg: Uint8Array): Uint8Array; +} + +/** Ecc implementation using WebAssembly */ +export const EccWasm = ffi.Ecc; +/** Download and initialize the WASM module, or use the provided buffer */ +export const initWasm = __wbg_init; + +/** Dummy Ecc impl that always returns 0, useful for measuring tx size */ +export class EccDummy implements Ecc { + derivePubkey(_seckey: Uint8Array): Uint8Array { + return new Uint8Array(33); + } + + ecdsaSign(_seckey: Uint8Array, _msg: Uint8Array): Uint8Array { + return new Uint8Array(73); + } + + schnorrSign(_seckey: Uint8Array, _msg: Uint8Array): Uint8Array { + return new Uint8Array(64); + } +} diff --git a/modules/ecash-lib/src/hash.test.ts b/modules/ecash-lib/src/hash.test.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/hash.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { expect } from 'chai'; +import fs from 'node:fs/promises'; + +import { fromHex, toHex, toHexRev } from './io/hex.js'; +import { initWasm } from './ecc.js'; +import { sha256, sha256d, shaRmd160 } from './hash.js'; + +const GENESIS_HEADER_HEX = + '01000000' + + '0000000000000000000000000000000000000000000000000000000000000000' + + '3ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a' + + '29ab5f49ffff001d1dac2b7c'; + +describe('Ecc', async () => { + // Can't use `fetch` for local file so we have to read it using `fs` + await initWasm(fs.readFile('./src/ffi/ecash_lib_wasm_bg.wasm')); + + it('sha256', () => { + expect(toHex(sha256(new Uint8Array()))).to.equal( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); + it('shaRmd160', () => { + expect(toHex(shaRmd160(new Uint8Array()))).to.equal( + 'b472a266d0bd89c13706a4132ccfb16f7c3b9fcb', + ); + }); + it('sha256d', () => { + expect(toHex(sha256d(new Uint8Array()))).to.equal( + '5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456', + ); + expect(toHexRev(sha256d(fromHex(GENESIS_HEADER_HEX)))).to.equal( + '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', + ); + }); +}); diff --git a/modules/ecash-lib/src/hash.ts b/modules/ecash-lib/src/hash.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/hash.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import * as ffi from './ffi/ecash_lib_wasm.js'; + +export const sha256 = ffi.sha256; +export const sha256d = ffi.sha256d; +export const shaRmd160 = ffi.shaRmd160; diff --git a/modules/ecash-lib/src/index.ts b/modules/ecash-lib/src/index.ts --- a/modules/ecash-lib/src/index.ts +++ b/modules/ecash-lib/src/index.ts @@ -2,6 +2,8 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +export * from './ecc.js'; +export * from './hash.js'; export * from './script.js'; export * from './tx.js'; export * from './io/bytes.js'; diff --git a/modules/ecash-lib/tsconfig.json b/modules/ecash-lib/tsconfig.json --- a/modules/ecash-lib/tsconfig.json +++ b/modules/ecash-lib/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { /* Language and Environment */ "target": "es2020", - "lib": ["ES2020"], + /* ES2020 for bigint, DOM for WebAssemby */ + "lib": ["ES2020", "DOM"], /* Modules */ "module": "NodeNext", "moduleResolution": "NodeNext",