Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions cpp/HybridNativeUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>& signature,
const std::shared_ptr<ArrayBuffer>& message,
const std::shared_ptr<ArrayBuffer>& pubKey,
bool prehash,
bool lowS,
const std::string& format) {
initializeContext();

const uint8_t* sigBytes = static_cast<const uint8_t*>(signature->data());
size_t sigLen = signature->size();
const uint8_t* msgBytes = static_cast<const uint8_t*>(message->data());
size_t msgLen = message->size();
const uint8_t* pubKeyBytes = static_cast<const uint8_t*>(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
1 change: 1 addition & 0 deletions cpp/HybridNativeUtils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class HybridNativeUtils : public HybridNativeUtilsSpec {
std::shared_ptr<ArrayBuffer> keccak256FromBytes(const std::shared_ptr<ArrayBuffer>& data) override;
std::shared_ptr<ArrayBuffer> pubToAddress(const std::shared_ptr<ArrayBuffer>& pubKey, bool sanitize = false) override;
std::shared_ptr<ArrayBuffer> hmacSha512(const std::shared_ptr<ArrayBuffer>& key, const std::shared_ptr<ArrayBuffer>& data) override;
bool ecdsaVerify(const std::shared_ptr<ArrayBuffer>& signature, const std::shared_ptr<ArrayBuffer>& message, const std::shared_ptr<ArrayBuffer>& pubKey, bool prehash, bool lowS, const std::string& format) override;
};

} // namespace margelo::nitro::metamask_nativeutils
2 changes: 1 addition & 1 deletion scripts/build-botan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/NativeUtils.nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
65 changes: 65 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Loading