Skip to content

Wrong Secp256k1 Key Parsing #290

@ckeshava

Description

@ckeshava

Vulnerability Overview

  • Repository: XRPLF/xrpl-rust
  • Type: Cryptographic Weakness
  • Severity: LOW
  • Confidence: HIGH
  • Location: src/core/keypairs/algorithms.rs (lines 275–277)

Description

Secp256k1::sign strips leading 0 characters from secp256k1 private keys before parsing them. For rare keys that begin with 00, the function can parse or sign with the wrong secret, producing signatures that fail verification against the intended public key.

Detailed Analysis

Code Context

The core::keypairs module exposes public helpers for seed generation, keypair derivation, signing, and signature verification. sign() dispatches to Ed25519 or secp256k1 based on the key prefix, then forwards the caller-supplied private key into the algorithm implementation.

Vulnerability Details

Secp256k1::sign should parse the full 32-byte private key string as provided. Instead, it removes all leading 0 characters with trim_start_matches('0') before calling SecretKey::from_str, which can change the key being signed with or cause parse failure.

Impact

Affected callers can generate signatures that do not match the intended secp256k1 public key, causing message verification or XRPL transaction submission to fail. The issue affects signing integrity and reliability, but does not expose secrets or grant new access.

Exploit Scenario

An application calls xrpl::core::keypairs::sign() with a secp256k1 private key whose first byte is 0x00. The signer strips the leading zeroes, signs with a different secret, and returns a valid-looking signature that later fails verification and is rejected.

Reachability Evidence

Verdict: REACHABLE
Summary: Both conditions are satisfied: callers can reach the vulnerable secp256k1 signing path by directly calling the public sign API with a 00... private key, and such keys are a rare but real edge case occurring with approximately 1/256 probability.

Condition Assessments

MET: The caller invokes the public sign function (src/core/keypairs/mod.rs) directly with a secp256k1 private key string whose first byte (key[0]) is exactly 0x00, bypassing the derive_keypair validation path which would have caught the mismatch.

src/core/keypairs/mod.rs exposes pub fn sign(message, private_key) as a public API that accepts any caller-supplied key string and directly dispatches based on the first two characters. _get_algorithm_engine_from_key treats any key not starting with "ED" as secp256k1, so a "00..." key is routed to Secp256k1::sign. That signer trims leading '0' characters before parsing, while derive_keypair separately signs a fixed test message and verifies it against the derived public key before returning the pair. Therefore, a caller can invoke the public sign API directly with a secp256k1 key beginning with 0x00 and avoid the derive_keypair validation check that would have rejected a mismatched key/signature pair.

src/core/keypairs/mod.rs:231-234

pub fn sign(message: &[u8], private_key: &str) -> XRPLCoreResult<String> {
    let module = _get_algorithm_engine_from_key(private_key);
    Ok(hex::encode_upper(module.sign(message, private_key)?))
}

MET: The probability of a randomly-derived SECP256K1 key having key[0] = 0x00 is approximately 1/256 (≈ 0.4%), making this an edge-case affecting a small but real subset of keys.

The secp256k1 derivation code produces a 32-byte candidate secret by taking sha512_first_half(...), and _is_secret_valid only constrains that 32-byte value to the normal secp256k1 scalar range 1..=curve_order; it does not filter or special-case a leading 0x00 byte. For randomly generated seeds, generate_seed uses Hc128Rng::from_entropy(), so the resulting secp256k1 secrets are derived from fresh random entropy. Because the private key is a 32-byte big-endian value and nothing in the code excludes first-byte 0x00, the chance that the most significant byte equals 0x00 is approximately 1 out of 256 (about 0.39%). The curve-order bound is very close to 2^256, so this remains an edge case affecting a small but real subset of keys.

src/core/keypairs/algorithms.rs:128-137

    fn _get_secret(
        input: &[u8],
        phase: &Secp256k1Phase,
    ) -> XRPLCoreResult<[u8; SHA512_HASH_LENGTH]> {
        for raw_root in 0..SECP256K1_SEQUENCE_MAX {
            let root = (raw_root as u32).to_be_bytes();
            let candidate = sha512_first_half(&Self::_candidate_merger(input, &root, phase));

            if Self::_is_secret_valid(candidate) {
                return Ok(candidate);

Metadata

Metadata

Assignees

No one assigned

    Labels

    AI TriageIssue reported via AI-assisted analysis; needs human triage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions