Skip to content

Commit cbf0321

Browse files
authored
Merge pull request #1601 from CosmWasm/low-high-s-comments
Improve comments on high/low-S handling
2 parents 25d48ee + 74ddd30 commit cbf0321

File tree

1 file changed

+71
-27
lines changed

1 file changed

+71
-27
lines changed

packages/crypto/src/secp256k1.rs

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ pub const ECDSA_PUBKEY_MAX_LEN: usize = ECDSA_UNCOMPRESSED_PUBKEY_LEN;
3535
/// - signature: Serialized "compact" signature (64 bytes).
3636
/// - public key: [Serialized according to SEC 2](https://www.oreilly.com/library/view/programming-bitcoin/9781492031482/ch04.html)
3737
/// (33 or 65 bytes).
38+
///
39+
/// This implementation accepts both high-S and low-S signatures. Some applications
40+
/// including Ethereum transactions consider high-S signatures invalid in order to
41+
/// avoid malleability. If that's the case for your protocol, the signature needs
42+
/// to be tested for low-S in addition to this verification.
3843
pub fn secp256k1_verify(
3944
message_hash: &[u8],
4045
signature: &[u8],
@@ -49,7 +54,10 @@ pub fn secp256k1_verify(
4954

5055
let mut signature =
5156
Signature::from_bytes(&signature).map_err(|e| CryptoError::generic_err(e.to_string()))?;
52-
// Non low-S signatures require normalization
57+
58+
// High-S signatures require normalization since our verification implementation
59+
// rejects them by default. If we had a verifier that does not restrict to
60+
// low-S only, this step was not needed.
5361
if let Some(normalized) = signature.normalize_s() {
5462
signature = normalized;
5563
}
@@ -74,6 +82,22 @@ pub fn secp256k1_verify(
7482
///
7583
/// Returns the recovered pubkey in compressed form, which can be used
7684
/// in secp256k1_verify directly.
85+
///
86+
/// This implementation accepts both high-S and low-S signatures. This is the
87+
/// same behavior as Ethereum's `ecrecover`. The reason is that high-S signatures
88+
/// may be perfectly valid if the application protocol does not disallow them.
89+
/// Or as [EIP-2] put it "The ECDSA recover precompiled contract remains unchanged
90+
/// and will keep accepting high s-values; this is useful e.g. if a contract
91+
/// recovers old Bitcoin signatures.".
92+
///
93+
/// See also OpenZeppelin's [ECDSA.recover implementation](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.1/contracts/utils/cryptography/ECDSA.sol#L138-L149)
94+
/// which adds further restrictions to avoid potential signature malleability.
95+
/// Please note that restricting signatures to low-S does not make signatures unique
96+
/// in the sense that for each (pubkey, message) there is only one signature. The
97+
/// signer can generate an arbitrary amount of valid signatures.
98+
/// <https://medium.com/@simonwarta/signature-determinism-for-blockchain-developers-dbd84865a93e>
99+
///
100+
/// [EIP-2]: https://eips.ethereum.org/EIPS/eip-2
77101
pub fn secp256k1_recover_pubkey(
78102
message_hash: &[u8],
79103
signature: &[u8],
@@ -161,7 +185,10 @@ mod tests {
161185
elliptic_curve::rand_core::OsRng,
162186
elliptic_curve::sec1::ToEncodedPoint,
163187
};
188+
use serde::Deserialize;
164189
use sha2::Sha256;
190+
use std::fs::File;
191+
use std::io::BufReader;
165192

166193
// For generic signature verification
167194
const MSG: &str = "Hello World!";
@@ -181,6 +208,15 @@ mod tests {
181208
// Test data originally from https://github.com/cosmos/cosmjs/blob/v0.24.0-alpha.22/packages/crypto/src/secp256k1.spec.ts#L195-L394
182209
const COSMOS_SECP256K1_TESTS_JSON: &str = "./testdata/secp256k1_tests.json";
183210

211+
#[derive(Deserialize, Debug)]
212+
struct Encoded {
213+
message: String,
214+
message_hash: String,
215+
signature: String,
216+
#[serde(rename = "pubkey")]
217+
public_key: String,
218+
}
219+
184220
#[test]
185221
fn test_secp256k1_verify() {
186222
// Explicit / external hashing
@@ -256,30 +292,13 @@ mod tests {
256292
let message_hash = Sha256::digest(message);
257293

258294
// secp256k1_verify works
259-
assert!(
260-
secp256k1_verify(&message_hash, &signature, &public_key).unwrap(),
261-
"secp256k1_verify() failed (test case {})",
262-
i
263-
);
295+
let valid = secp256k1_verify(&message_hash, &signature, &public_key).unwrap();
296+
assert!(valid, "secp256k1_verify() failed (test case {i})",);
264297
}
265298
}
266299

267300
#[test]
268301
fn test_cosmos_extra_secp256k1_verify() {
269-
use std::fs::File;
270-
use std::io::BufReader;
271-
272-
use serde::Deserialize;
273-
274-
#[derive(Deserialize, Debug)]
275-
struct Encoded {
276-
message: String,
277-
message_hash: String,
278-
signature: String,
279-
#[serde(rename = "pubkey")]
280-
public_key: String,
281-
}
282-
283302
// Open the file in read-only mode with buffer.
284303
let file = File::open(COSMOS_SECP256K1_TESTS_JSON).unwrap();
285304
let reader = BufReader::new(file);
@@ -288,20 +307,18 @@ mod tests {
288307

289308
for (i, encoded) in (1..).zip(codes) {
290309
let message = hex::decode(&encoded.message).unwrap();
310+
let signature = hex::decode(&encoded.signature).unwrap();
311+
let public_key = hex::decode(&encoded.public_key).unwrap();
291312

292313
let hash = hex::decode(&encoded.message_hash).unwrap();
293314
let message_hash = Sha256::digest(message);
294315
assert_eq!(hash.as_slice(), message_hash.as_slice());
295316

296-
let signature = hex::decode(&encoded.signature).unwrap();
297-
298-
let public_key = hex::decode(&encoded.public_key).unwrap();
299-
300317
// secp256k1_verify() works
318+
let valid = secp256k1_verify(&message_hash, &signature, &public_key).unwrap();
301319
assert!(
302-
secp256k1_verify(&message_hash, &signature, &public_key).unwrap(),
303-
"verify() failed (test case {})",
304-
i
320+
valid,
321+
"secp256k1_verify failed (test case {i} in {COSMOS_SECP256K1_TESTS_JSON})"
305322
);
306323
}
307324
}
@@ -327,6 +344,7 @@ mod tests {
327344
}
328345

329346
// Test data from https://github.com/randombit/botan/blob/2.9.0/src/tests/data/pubkey/ecdsa_key_recovery.vec
347+
// This is a high-s value (`0x81F1A4457589F30D76AB9F89E748A68C8A94C30FE0BAC8FB5C0B54EA70BF6D2F > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0` is true)
330348
{
331349
let expected_x = "F3F8BB913AA68589A2C8C607A877AB05252ADBD963E1BE846DDEB8456942AEDC";
332350
let expected_y = "A2ED51F08CA3EF3DAC0A7504613D54CD539FC1B3CBC92453CD704B6A2D012B2C";
@@ -349,6 +367,32 @@ mod tests {
349367
let pubkey = secp256k1_recover_pubkey(&message_hash, &r_s, recovery_param).unwrap();
350368
assert_eq!(pubkey, expected);
351369
}
370+
371+
let file = File::open(COSMOS_SECP256K1_TESTS_JSON).unwrap();
372+
let reader = BufReader::new(file);
373+
let codes: Vec<Encoded> = serde_json::from_reader(reader).unwrap();
374+
for (i, encoded) in (1..).zip(codes) {
375+
let message = hex::decode(&encoded.message).unwrap();
376+
let signature = hex::decode(&encoded.signature).unwrap();
377+
let public_key = hex::decode(&encoded.public_key).unwrap();
378+
379+
let hash = hex::decode(&encoded.message_hash).unwrap();
380+
let message_hash = Sha256::digest(message);
381+
assert_eq!(hash.as_slice(), message_hash.as_slice());
382+
383+
// Since the recovery param is missing in the test vectors, we try both 0 and 1
384+
let try0 = secp256k1_recover_pubkey(&message_hash, &signature, 0);
385+
let try1 = secp256k1_recover_pubkey(&message_hash, &signature, 1);
386+
match (try0, try1) {
387+
(Ok(recovered0), Ok(recovered1)) => {
388+
// Got two different pubkeys. Without the recovery param, we don't know which one is the right one.
389+
assert!(recovered0 == public_key || recovered1 == public_key)
390+
},
391+
(Ok(recovered), Err(_)) => assert_eq!(recovered, public_key),
392+
(Err(_), Ok(recovered)) => assert_eq!(recovered, public_key),
393+
(Err(_), Err(_)) => panic!("secp256k1_recover_pubkey failed (test case {i} in {COSMOS_SECP256K1_TESTS_JSON})"),
394+
}
395+
}
352396
}
353397

354398
#[test]

0 commit comments

Comments
 (0)