From 0fde4824192ced7ecb21042284b617f990b691db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Tue, 9 Dec 2025 15:38:58 +0100 Subject: [PATCH 1/2] feat: add secpt256k1 verify function --- cpp/HybridNativeUtils.cpp | 101 ++++++++++++++++++++++++++++++++++++++ cpp/HybridNativeUtils.hpp | 1 + scripts/build-botan.sh | 2 +- src/NativeUtils.nitro.ts | 8 +++ src/index.tsx | 65 ++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 1 deletion(-) diff --git a/cpp/HybridNativeUtils.cpp b/cpp/HybridNativeUtils.cpp index 20f0de1..3976b68 100644 --- a/cpp/HybridNativeUtils.cpp +++ b/cpp/HybridNativeUtils.cpp @@ -207,4 +207,105 @@ double HybridNativeUtils::multiply(double a, double b) { return a * b; } +static void sha256Hash(const uint8_t* data, size_t dataLen, uint8_t* output) { + auto hasher = Botan::HashFunction::create("SHA-256"); + if (!hasher) { + throw std::runtime_error("Failed to create SHA-256 hasher"); + } + hasher->update(data, dataLen); + hasher->final(output); +} + +bool HybridNativeUtils::ecdsaVerify( + const std::shared_ptr& signature, + const std::shared_ptr& message, + const std::shared_ptr& pubKey, + bool prehash, + bool lowS, + const std::string& format) { + initializeContext(); + + const uint8_t* sigBytes = static_cast(signature->data()); + size_t sigLen = signature->size(); + const uint8_t* msgBytes = static_cast(message->data()); + size_t msgLen = message->size(); + const uint8_t* pubKeyBytes = static_cast(pubKey->data()); + size_t pubKeyLen = pubKey->size(); + + // Parse the signature based on format + secp256k1_ecdsa_signature sig; + + if (format == "compact") { + if (sigLen != 64) { + return false; + } + if (!secp256k1_ecdsa_signature_parse_compact(g_ctx, &sig, sigBytes)) { + return false; + } + } else if (format == "recovered") { + // Recovered format is 65 bytes: recovery byte + 64 byte compact signature + if (sigLen != 65) { + return false; + } + // Skip the first byte (recovery byte) and parse the remaining 64 bytes as compact + if (!secp256k1_ecdsa_signature_parse_compact(g_ctx, &sig, sigBytes + 1)) { + return false; + } + } else if (format == "der") { + // Defensive checks for DER format to prevent crashes on malformed input + // Valid secp256k1 DER signatures are typically 70-72 bytes, max ~73 bytes + // Minimum is 8 bytes (2 for SEQUENCE header + 2x3 for minimal integers) + // Strict DER validation prevents parsing ambiguities and improves security + if (sigLen < 8 || sigLen > 73) { + return false; + } + // Must start with SEQUENCE tag (0x30) + if (sigBytes[0] != 0x30) { + return false; + } + // The length byte should indicate remaining length + // For short form (which all valid ECDSA sigs use), it should be sigLen - 2 + if (sigBytes[1] != sigLen - 2) { + return false; + } + if (!secp256k1_ecdsa_signature_parse_der(g_ctx, &sig, sigBytes, sigLen)) { + return false; + } + } else { + throw std::runtime_error("Invalid signature format. Must be 'compact', 'recovered', or 'der'"); + } + + // Check if signature has high S and handle accordingly + // secp256k1_ecdsa_signature_normalize returns 1 if the signature had high S (was normalized) + // We always normalize the signature for verification (both (r,s) and (r,n-s) are mathematically valid) + // but only reject high-S when lowS=true + bool wasHighS = secp256k1_ecdsa_signature_normalize(g_ctx, &sig, &sig) == 1; + + // Reject high-S signatures if lowS enforcement is requested + if (lowS && wasHighS) { + return false; + } + + // Compute message hash if prehash is true + uint8_t msgHash[32]; + if (prehash) { + sha256Hash(msgBytes, msgLen, msgHash); + } else { + // Message should already be a 32-byte hash + if (msgLen != 32) { + return false; + } + memcpy(msgHash, msgBytes, 32); + } + + // Parse the public key + secp256k1_pubkey parsedPubKey; + if (!secp256k1_ec_pubkey_parse(g_ctx, &parsedPubKey, pubKeyBytes, pubKeyLen)) { + return false; + } + + // Verify the signature + return secp256k1_ecdsa_verify(g_ctx, &sig, msgHash, &parsedPubKey) == 1; +} + } // namespace margelo::nitro::metamask_nativeutils \ No newline at end of file diff --git a/cpp/HybridNativeUtils.hpp b/cpp/HybridNativeUtils.hpp index 9d87658..ecfe332 100644 --- a/cpp/HybridNativeUtils.hpp +++ b/cpp/HybridNativeUtils.hpp @@ -17,6 +17,7 @@ class HybridNativeUtils : public HybridNativeUtilsSpec { std::shared_ptr keccak256FromBytes(const std::shared_ptr& data) override; std::shared_ptr pubToAddress(const std::shared_ptr& pubKey, bool sanitize = false) override; std::shared_ptr hmacSha512(const std::shared_ptr& key, const std::shared_ptr& data) override; + bool ecdsaVerify(const std::shared_ptr& signature, const std::shared_ptr& message, const std::shared_ptr& pubKey, bool prehash, bool lowS, const std::string& format) override; }; } // namespace margelo::nitro::metamask_nativeutils diff --git a/scripts/build-botan.sh b/scripts/build-botan.sh index 6312afb..cb7d545 100755 --- a/scripts/build-botan.sh +++ b/scripts/build-botan.sh @@ -21,7 +21,7 @@ echo "Output directory: $OUTPUT_DIR" mkdir -p "$BOTAN_GENERATED_DIR" # Configuration variables -BOTAN_MODULES="keccak,hmac,sha2_64,ed25519" +BOTAN_MODULES="keccak,hmac,sha2_32,sha2_64,ed25519" COMMON_FLAGS="--amalgamation --minimized-build --disable-cc-tests" echo "📦 Using modules: $BOTAN_MODULES" diff --git a/src/NativeUtils.nitro.ts b/src/NativeUtils.nitro.ts index bc14e75..20eb00f 100644 --- a/src/NativeUtils.nitro.ts +++ b/src/NativeUtils.nitro.ts @@ -13,4 +13,12 @@ export interface NativeUtils keccak256FromBytes(data: ArrayBuffer): ArrayBuffer; pubToAddress(pubKey: ArrayBuffer, sanitize: boolean): ArrayBuffer; hmacSha512(key: ArrayBuffer, data: ArrayBuffer): ArrayBuffer; + ecdsaVerify( + signature: ArrayBuffer, + message: ArrayBuffer, + pubKey: ArrayBuffer, + prehash: boolean, + lowS: boolean, + format: string, + ): boolean; } diff --git a/src/index.tsx b/src/index.tsx index 4a33fec..3a9cba3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -177,3 +177,68 @@ export function getPublicKeyEd25519( return arrayBufferToUint8Array(result); } + +/** Signature format for ECDSA verification */ +export type ECDSASignatureFormat = 'compact' | 'recovered' | 'der'; + +/** Options for ECDSA signature verification */ +export type ECDSAVerifyOpts = { + /** If true (default), hash the message with SHA-256 before verification */ + prehash?: boolean; + /** If true (default), reject signatures with high S value */ + lowS?: boolean; + /** Signature format: 'compact' (64 bytes), 'recovered' (65 bytes), or 'der' */ + format?: ECDSASignatureFormat; +}; + +/** + * Verify an ECDSA signature using the secp256k1 curve. + * This is a fast native implementation that matches the noble/curves secp256k1.verify API. + * + * SECURITY NOTES: + * - Enforces malleability protection by rejecting high-S signatures (lowS=true by default) + * - DER format parsing is strictly compliant with RFC 5912 (rejects BER encoding for security) + * - Validates all cryptographic parameters (r, s within curve order, valid public keys) + * + * @param signature - The signature as Uint8Array (64 bytes compact, 65 bytes recovered, or variable DER) + * @param message - The message as Uint8Array (raw message if prehash=true, 32-byte hash if prehash=false) + * @param publicKey - The public key as Uint8Array (33 bytes compressed or 65 bytes uncompressed) + * @param opts - Verification options + * @param opts.prehash - If true (default), hash message with SHA-256 before verification + * @param opts.lowS - If true (default), reject signatures with high S value (prevents malleability) + * @param opts.format - Signature format: 'compact' (default), 'recovered', or 'der' + * @returns true if the signature is valid, false otherwise + */ +export function verify( + signature: Uint8Array, + message: Uint8Array, + publicKey: Uint8Array, + opts?: ECDSAVerifyOpts, +): boolean { + const prehash = opts?.prehash ?? true; + const lowS = opts?.lowS ?? true; + const format = opts?.format ?? 'compact'; + + if (!(signature instanceof Uint8Array)) { + throw new Error('Signature must be a Uint8Array'); + } + if (!(message instanceof Uint8Array)) { + throw new Error('Message must be a Uint8Array'); + } + if (!(publicKey instanceof Uint8Array)) { + throw new Error('Public key must be a Uint8Array'); + } + + const sigBuffer = uint8ArrayToArrayBuffer(signature); + const msgBuffer = uint8ArrayToArrayBuffer(message); + const pubKeyBuffer = uint8ArrayToArrayBuffer(publicKey); + + return NativeUtilsHybridObject.ecdsaVerify( + sigBuffer, + msgBuffer, + pubKeyBuffer, + prehash, + lowS, + format, + ); +} From 15611c2b3bc083b86ef121a8c34d155937815a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Tue, 9 Dec 2025 15:43:03 +0100 Subject: [PATCH 2/2] chore: add tests for secpt256k1 for verify function --- example/src/App.tsx | 119 + .../src/benchmarks/ecdsaVerifyBenchmark.ts | 356 + example/src/tests/ecdsaVerifyTests.ts | 1738 +++ example/src/vectors/secp256k1-ecdsa.json | 10372 ++++++++++++++++ .../vectors/wycheproof-ecdsa-secp256k1.json | 6356 ++++++++++ package.json | 3 + yarn.lock | 1606 ++- 7 files changed, 20506 insertions(+), 44 deletions(-) create mode 100644 example/src/benchmarks/ecdsaVerifyBenchmark.ts create mode 100644 example/src/tests/ecdsaVerifyTests.ts create mode 100644 example/src/vectors/secp256k1-ecdsa.json create mode 100644 example/src/vectors/wycheproof-ecdsa-secp256k1.json diff --git a/example/src/App.tsx b/example/src/App.tsx index 24109b9..5c4486f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -52,6 +52,10 @@ import { runAllEd25519Benchmarks, type BenchmarkResult as Ed25519BenchmarkResult, } from './benchmarks/ed25519Benchmark'; +import { + runAllEcdsaVerifyBenchmarks, + type BenchmarkResult as EcdsaVerifyBenchmarkResult, +} from './benchmarks/ecdsaVerifyBenchmark'; import { testEd25519BasicFunctionality, testEd25519PublicKeyFormat, @@ -66,6 +70,7 @@ import { verifyMultipleEd25519Vectors, type Ed25519VerificationResult, } from './tests/ed25519NobleCompatibilityTests'; +import { runAllEcdsaVerifyTests } from './tests/ecdsaVerifyTests'; // Define test suite configuration interface TestSuite { @@ -91,6 +96,7 @@ export default function App() { ed25519: TestResult[]; ed25519Noble: TestResult[]; ed25519Verification: Ed25519VerificationResult[]; + ecdsaVerify: TestResult[]; }>({ basic: [], noble: [], @@ -103,6 +109,7 @@ export default function App() { ed25519: [], ed25519Noble: [], ed25519Verification: [], + ecdsaVerify: [], }); const [benchmarkResults, setBenchmarkResults] = useState<{ @@ -111,12 +118,14 @@ export default function App() { pubToAddressSuite: PubToAddressBenchmarkResult[] | null; keccak256Suite: Keccak256BenchmarkResult[] | null; ed25519Suite: Ed25519BenchmarkResult[] | null; + ecdsaVerifySuite: EcdsaVerifyBenchmarkResult[] | null; }>({ suite: null, hmacSuite: null, pubToAddressSuite: null, keccak256Suite: null, ed25519Suite: null, + ecdsaVerifySuite: null, }); const [isRunning, setIsRunning] = useState(false); @@ -201,6 +210,11 @@ export default function App() { key: 'ed25519Verification', runner: () => verifyMultipleEd25519Vectors(), }, + { + name: 'ECDSA Verify - secp256k1 signature verification', + key: 'ecdsaVerify', + runner: () => runAllEcdsaVerifyTests(), + }, ]; const clearAllResults = () => { @@ -217,6 +231,7 @@ export default function App() { ed25519: [], ed25519Noble: [], ed25519Verification: [], + ecdsaVerify: [], }); setBenchmarkResults({ suite: null, @@ -224,6 +239,7 @@ export default function App() { pubToAddressSuite: null, keccak256Suite: null, ed25519Suite: null, + ecdsaVerifySuite: null, }); }; @@ -340,6 +356,7 @@ export default function App() { ...testResults.ed25519.map((r) => ({ success: r.success })), ...testResults.ed25519Noble.map((r) => ({ success: r.success })), ...testResults.ed25519Verification.map((r) => ({ success: r.matches })), + ...testResults.ecdsaVerify.map((r) => ({ success: r.success })), ]; const totalTests = allResults.length; @@ -371,6 +388,8 @@ export default function App() { passed = results.filter((r: TestResult) => r.success).length; } else if (key === 'keccak256') { passed = results.filter((r: TestResult) => r.success).length; + } else if (key === 'ecdsaVerify') { + passed = results.filter((r: TestResult) => r.success).length; } else { passed = results.filter((r: TestResult) => r.success).length; } @@ -416,6 +435,17 @@ export default function App() { disabled={isRunning} /> + +