├── .github └── workflows │ └── checks.yml ├── LICENSE ├── README.md ├── requirements.txt └── scripts └── recover_private_key.py /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: 👮‍♂️ Sanity checks 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | prettify-n-test: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: 15 | - ubuntu-latest 16 | architecture: 17 | - x64 18 | python_version: 19 | - 3.13 20 | node_version: 21 | - 22 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Use Node.js ${{ matrix.node_version }} 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node_version }} 31 | 32 | - name: Run Prettier 33 | run: npx prettier -c '**/*.{md,yml,yaml}' 34 | 35 | - name: Setup Python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: ${{ matrix.python_version }} 39 | architecture: ${{ matrix.architecture }} 40 | 41 | - name: Check formatting with Black 42 | uses: psf/black@stable 43 | with: 44 | options: "--check --verbose" 45 | src: "./scripts" 46 | 47 | - name: Check private key recovery 48 | run: | 49 | pip install -r requirements.txt 50 | python scripts/recover_private_key.py 51 | 52 | codespell: 53 | runs-on: ${{ matrix.os }} 54 | strategy: 55 | matrix: 56 | os: 57 | - ubuntu-latest 58 | 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v4 62 | 63 | - name: Run codespell 64 | uses: codespell-project/actions-codespell@v2 65 | with: 66 | check_filenames: true 67 | skip: ./.git 68 | 69 | validate-links: 70 | runs-on: ${{ matrix.os }} 71 | strategy: 72 | matrix: 73 | os: 74 | - ubuntu-latest 75 | ruby_version: 76 | - 3.4 77 | 78 | steps: 79 | - name: Checkout 80 | uses: actions/checkout@v4 81 | 82 | - name: Set up Ruby 83 | uses: ruby/setup-ruby@v1 84 | with: 85 | ruby-version: ${{ matrix.ruby_version }} 86 | bundler-cache: true 87 | 88 | - name: Install awesome_bot 89 | run: gem install awesome_bot 90 | 91 | - name: Validate URLs 92 | run: awesome_bot ./*.md scripts/*.py --allow-dupe --allow-redirect --request-delay 0.4 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Pascal Marco Caversaccio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🛡️ ECDSA Nonce Reuse Attack 2 | 3 | [![👮‍♂️ Sanity checks](https://github.com/pcaversaccio/ecdsa-nonce-reuse-attack/actions/workflows/checks.yml/badge.svg)](https://github.com/pcaversaccio/ecdsa-nonce-reuse-attack/actions/workflows/checks.yml) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/license/mit) 5 | 6 | This repository implements a Python function [`recover_private_key`](https://github.com/pcaversaccio/ecdsa-nonce-reuse-attack/blob/main/scripts/recover_private_key.py) that recovers the private key from two different signatures that use the same random nonce $k$ during signature generation. Note that if the same $k$ is used in two signatures, this implies that the secp256k1 32-byte signature parameter $r$ is identical. This property is asserted in this function. 7 | 8 | ## 🧠 Mathematical Derivation 9 | 10 | First, note that the integer order $n$ of $G$ (a base point of prime order on the curve) for the [secp256k1 elliptic curve](https://en.bitcoin.it/wiki/Secp256k1) is: 11 | 12 | ```console 13 | # Represented as hex value. 14 | n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd036414 15 | # Represented as integer value. 16 | n = 115792089237316195423570985008687907852837564279074904382605163141518161494337 17 | ``` 18 | 19 | #### 1. Public-Private-Key-Relationship 20 | 21 | 22 | $$ Q_{A} = d_{A} \cdot G $$ 23 | 24 | 25 | $Q_{A}$ is the public key, $d_{A}$ is the private key, and $G$ is the elliptic curve base point. 26 | 27 | #### 2. The secp256k1 32-Byte Signature Parameter $r$ 28 | 29 | 30 | $$ r = G \cdot k \quad \left(\textnormal{mod} \enspace n\right) $$ 31 | 32 | 33 | $r$ is the first secp256k1 32-byte signature parameter, $n$ is the integer order of $G$, and $k$ is the random nonce value. 34 | 35 | #### 3. The secp256k1 32-Byte Signature Parameter $s$ 36 | 37 | 38 | $$ s = \frac{h + d_{A} \cdot r}{k} \quad \left(\textnormal{mod} \enspace n\right) $$ 39 | 40 | 41 | $s$ is the second secp256k1 32-byte signature parameter and $h$ is the 32-byte message digest of a message. 42 | 43 | #### 4. Recover the Private Key 44 | 45 | Let's assume that $d_{A}$ has used the same random value $k$ for two different signatures. This implies from the above definition of $r$ that $r$ is the same for both signatures, since $G$ and $n$ are constants. Thus, we have: 46 | 47 | 48 | $$ s_{1} = \frac{h_{1} + d_{A} \cdot r}{k} \quad \left(\textnormal{mod} \enspace n\right) $$ 49 | 50 | 51 | and 52 | 53 | 54 | $$ s_{2} = \frac{h_{2} + d_{A} \cdot r}{k} \quad \left(\textnormal{mod} \enspace n\right). $$ 55 | 56 | 57 | We can solve for $k$ with the above system of equations: 58 | 59 | 60 | $$ s_{1} - s_{2} = \frac{h_{1} + d_{A} \cdot r}{k} - \frac{h_{2} + d_{A} \cdot r}{k} \quad \left(\textnormal{mod} \enspace n\right), $$ 61 | 62 | $$ s_{1} - s_{2} = \frac{h_{1} + d_{A} \cdot r - h_{2} - d_{A} \cdot r}{k}\quad \left(\textnormal{mod} \enspace n\right), $$ 63 | 64 | $$ s_{1} - s_{2} = \frac{h_{1} - h_{2}}{k}\quad \left(\textnormal{mod} \enspace n\right), $$ 65 | 66 | $$ k = \frac{h_{1} - h_{2}}{s_{1} - s_{2}}\quad \left(\textnormal{mod} \enspace n\right). $$ 67 | 68 | 69 | Eventually, we can now plug $k$ into the equation $s_{1}$ and recover the private key $d_{A}$: 70 | 71 | 72 | $$ s_{1} = \frac{h_{1} + d_{A} \cdot r}{\frac{h_{1} - h_{2}}{s_{1} - s_{2}}} \quad \left(\textnormal{mod} \enspace n\right), $$ 73 | 74 | $$ s_{1} = \frac{\left(s_{1} - s_{2}\right)\cdot\left(h_{1} + d_{A} \cdot r\right)}{h_{1} - h_{2}} \quad \left(\textnormal{mod} \enspace n\right), $$ 75 | 76 | $$ d_{A} = \frac{(s_{2} \cdot h_{1} - s_{1} \cdot h_{2})}{r \cdot (s_{1} - s_{2})} \quad \left(\textnormal{mod} \enspace n\right). $$ 77 | 78 | 79 | > The function [`recover_private_key`](./scripts/recover_private_key.py) uses the last equation in conjunction with modular arithmetic properties to recover the private key. 80 | 81 | ## 📚 Further References 82 | 83 | - [Elliptic Curve Digital Signature Algorithm](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) 84 | - [RFC 6979](https://datatracker.ietf.org/doc/html/rfc6979) 85 | - [A Glimpse of the Deep: Finding a Creature in Ethereum's Dark Forest](https://bertcmiller.com/glimpse.html) 86 | - [How Hackers Can Exploit Weak ECDSA Signatures](https://www.halborn.com/blog/post/how-hackers-can-exploit-weak-ecdsa-signatures) 87 | - [ECDSA Nonce Reuse Exploit Example](https://github.com/Marsh61/ECDSA-Nonce-Reuse-Exploit-Example) 88 | - [Identifying Key Leakage of Bitcoin Users](https://link.springer.com/content/pdf/10.1007/978-3-030-00470-5_29.pdf) 89 | - [How Do You Derive the Private Key From Two Signatures That Share the Same `k` Value?](https://bitcoin.stackexchange.com/a/73624) 90 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ecdsa==0.19.1 2 | -------------------------------------------------------------------------------- /scripts/recover_private_key.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from hashlib import sha256 3 | from ecdsa import SECP256k1, SigningKey 4 | from ecdsa.util import sigdecode_string 5 | from ecdsa.numbertheory import inverse_mod 6 | 7 | 8 | def recover_private_key(h1, h2, s1, s2, r1, r2, n): 9 | """Recover the private key via nonce reuse. 10 | 11 | Recover the private key from two different signatures 12 | that use the same random nonce `k` during signature 13 | generation. Note that if the same `k` is used in two 14 | signatures, this implies that the secp256k1 32-byte 15 | signature parameter `r` is identical. This property is 16 | asserted in this function. 17 | 18 | Parameters 19 | ---------- 20 | h1: int 21 | The 32-byte message digest of the message `m1`. 22 | h2: int 23 | The 32-byte message digest of the message `m2`. 24 | s1: int 25 | The secp256k1 32-byte signature parameter `s1`. 26 | s2: int 27 | The secp256k1 32-byte signature parameter `s2`. 28 | r1: int 29 | The secp256k1 32-byte signature parameter `r1`. 30 | r2: int 31 | The secp256k1 32-byte signature parameter `r2`. 32 | n: int 33 | The 32-byte integer order of G (part of the public key). 34 | 35 | Returns 36 | ------- 37 | pk: int 38 | The recovered 32-byte private key. 39 | 40 | Raises 41 | ------ 42 | AssertionError 43 | No ECDSA nonce reuse detected. 44 | """ 45 | assert r1 == r2, "No ECDSA nonce reuse detected." 46 | return ((s2 * h1 - s1 * h2) * inverse_mod(r1 * (s1 - s2), n)) % n 47 | 48 | 49 | if __name__ == "__main__": 50 | """An illustrative recovery of the private key.""" 51 | m1 = b"wagmi1" 52 | m2 = b"wagmi2" 53 | k = 1337 54 | n = SECP256k1.order 55 | 56 | # Generate the signing key object. 57 | d_A = SigningKey.generate(curve=SECP256k1) 58 | # Retrieve the private key. 59 | original_private_key = d_A.privkey.secret_multiplier 60 | # Retrieve the public key. 61 | Q_A = d_A.verifying_key 62 | 63 | # Generate the message digests. 64 | h1 = sha256(m1).hexdigest() 65 | h2 = sha256(m2).hexdigest() 66 | 67 | # Generate the signatures using the same `k` value. 68 | signature_1 = d_A.sign(m1, hashfunc=sha256, k=k) 69 | signature_2 = d_A.sign(m2, hashfunc=sha256, k=k) 70 | 71 | # Retrieve the secp256k1 32-byte signature parameters `r` and `s`. 72 | (r1, s1) = sigdecode_string(signature_1, n) 73 | (r2, s2) = sigdecode_string(signature_2, n) 74 | 75 | # Recover the private key. 76 | recovered_private_key = recover_private_key( 77 | int(h1, base=16), int(h2, base=16), s1, s2, r1, r2, n 78 | ) 79 | 80 | print(f"Original private key: {original_private_key}") 81 | print(f"Recovered private key: {recovered_private_key}") 82 | assert ( 83 | original_private_key == recovered_private_key 84 | ), "Recovered private key does not equal the original private key." 85 | --------------------------------------------------------------------------------