Skip to content

Commit 4f5b1ce

Browse files
security: enforce low-S ECDSA signatures to match aptos-core
Aptos on-chain verification rejects high-S ECDSA signatures for both secp256k1 and secp256r1 to prevent signature malleability. The SDK was previously accepting both forms, which could lead to signatures that pass SDK validation but fail on-chain. - Normalize signatures to low-S at signing time (critical for p256 which does not guarantee low-S output; defense-in-depth for k256) - Reject high-S signatures in from_bytes/from_hex/deserialization - Reject high-S signatures in verify/verify_prehashed - Add tests for high-S rejection and low-S signing invariant Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 631ccd4 commit 4f5b1ce

File tree

2 files changed

+228
-27
lines changed

2 files changed

+228
-27
lines changed

crates/aptos-sdk/src/crypto/secp256k1.rs

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
//!
55
//! # Security: Signature Malleability
66
//!
7-
//! This implementation uses the `k256` crate which produces normalized (low-S)
8-
//! ECDSA signatures by default. This prevents signature malleability attacks
9-
//! where an attacker could create an alternative valid signature `(r, -s mod n)`
10-
//! for the same message.
7+
//! This implementation enforces **low-S only** signatures to match the Aptos
8+
//! blockchain's on-chain verification, which rejects high-S signatures to
9+
//! prevent signature malleability attacks.
1110
//!
12-
//! When parsing external signatures via [`Secp256k1Signature::from_bytes`], the
13-
//! `k256` crate accepts both low-S and high-S signatures. If strict low-S
14-
//! enforcement is required, callers should normalize or reject high-S signatures.
11+
//! - **Signing** always produces low-S signatures (the `k256` crate normalizes
12+
//! by default).
13+
//! - **Parsing** (`from_bytes`, `from_hex`) rejects high-S signatures.
14+
//! - **Verification** also rejects high-S signatures as a defense-in-depth
15+
//! measure.
1516
1617
use crate::crypto::traits::{PublicKey, Signature, Signer, Verifier};
1718
use crate::error::{AptosError, AptosResult};
@@ -125,18 +126,29 @@ impl Secp256k1PrivateKey {
125126
}
126127
}
127128

128-
/// Signs a message (pre-hashed with SHA256) and returns the signature.
129+
/// Signs a message (pre-hashed with SHA256) and returns a low-S signature.
130+
///
131+
/// The `k256` crate produces low-S signatures by default. An additional
132+
/// normalization step is included as defense-in-depth to guarantee Aptos
133+
/// on-chain compatibility.
129134
pub fn sign(&self, message: &[u8]) -> Secp256k1Signature {
130135
// Hash the message with SHA256 first (standard for ECDSA)
131136
let hash = crate::crypto::sha2_256(message);
132137
let signature: K256Signature = self.inner.sign(&hash);
133-
Secp256k1Signature { inner: signature }
138+
// SECURITY: Ensure low-S (defense-in-depth; k256 should already do this)
139+
let normalized = signature.normalize_s().unwrap_or(signature);
140+
Secp256k1Signature { inner: normalized }
134141
}
135142

136-
/// Signs a pre-hashed message directly.
143+
/// Signs a pre-hashed message directly and returns a low-S signature.
144+
///
145+
/// The `k256` crate produces low-S signatures by default. An additional
146+
/// normalization step is included as defense-in-depth.
137147
pub fn sign_prehashed(&self, hash: &[u8; 32]) -> Secp256k1Signature {
138148
let signature: K256Signature = self.inner.sign(hash);
139-
Secp256k1Signature { inner: signature }
149+
// SECURITY: Ensure low-S (defense-in-depth; k256 should already do this)
150+
let normalized = signature.normalize_s().unwrap_or(signature);
151+
Secp256k1Signature { inner: normalized }
140152
}
141153
}
142154

@@ -234,11 +246,21 @@ impl Secp256k1PublicKey {
234246

235247
/// Verifies a signature against a message.
236248
///
249+
/// # Security
250+
///
251+
/// Rejects high-S signatures before verification, matching Aptos on-chain
252+
/// behavior. This is a defense-in-depth check; signatures created through
253+
/// this SDK's `from_bytes` are already guaranteed to be low-S.
254+
///
237255
/// # Errors
238256
///
239-
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature is invalid or does not match the message.
257+
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature has
258+
/// a high-S value, is invalid, or does not match the message.
240259
pub fn verify(&self, message: &[u8], signature: &Secp256k1Signature) -> AptosResult<()> {
241-
// Hash the message with SHA256 first
260+
// SECURITY: Reject high-S signatures (matches aptos-core behavior)
261+
if signature.inner.normalize_s().is_some() {
262+
return Err(AptosError::SignatureVerificationFailed);
263+
}
242264
let hash = crate::crypto::sha2_256(message);
243265
self.inner
244266
.verify(&hash, &signature.inner)
@@ -247,14 +269,24 @@ impl Secp256k1PublicKey {
247269

248270
/// Verifies a signature against a pre-hashed message.
249271
///
272+
/// # Security
273+
///
274+
/// Rejects high-S signatures before verification, matching Aptos on-chain
275+
/// behavior.
276+
///
250277
/// # Errors
251278
///
252-
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature is invalid or does not match the hash.
279+
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature has
280+
/// a high-S value, is invalid, or does not match the hash.
253281
pub fn verify_prehashed(
254282
&self,
255283
hash: &[u8; 32],
256284
signature: &Secp256k1Signature,
257285
) -> AptosResult<()> {
286+
// SECURITY: Reject high-S signatures (matches aptos-core behavior)
287+
if signature.inner.normalize_s().is_some() {
288+
return Err(AptosError::SignatureVerificationFailed);
289+
}
258290
self.inner
259291
.verify(hash, &signature.inner)
260292
.map_err(|_| AptosError::SignatureVerificationFailed)
@@ -346,11 +378,18 @@ pub struct Secp256k1Signature {
346378
impl Secp256k1Signature {
347379
/// Creates a signature from raw bytes (64 bytes, r || s).
348380
///
381+
/// # Security
382+
///
383+
/// Rejects high-S signatures to match Aptos on-chain verification behavior.
384+
/// The Aptos VM only accepts low-S (canonical) ECDSA signatures to prevent
385+
/// signature malleability attacks.
386+
///
349387
/// # Errors
350388
///
351389
/// Returns [`AptosError::InvalidSignature`] if:
352390
/// - The byte slice length is not exactly 64 bytes
353391
/// - The bytes do not represent a valid Secp256k1 signature
392+
/// - The signature has a high-S value (not canonical)
354393
pub fn from_bytes(bytes: &[u8]) -> AptosResult<Self> {
355394
if bytes.len() != SECP256K1_SIGNATURE_LENGTH {
356395
return Err(AptosError::InvalidSignature(format!(
@@ -361,6 +400,15 @@ impl Secp256k1Signature {
361400
}
362401
let signature = K256Signature::from_slice(bytes)
363402
.map_err(|e| AptosError::InvalidSignature(e.to_string()))?;
403+
// SECURITY: Reject high-S signatures. Aptos on-chain verification only
404+
// accepts low-S (canonical) signatures to prevent malleability.
405+
// normalize_s() returns Some(_) if the signature was high-S.
406+
if signature.normalize_s().is_some() {
407+
return Err(AptosError::InvalidSignature(
408+
"high-S signature rejected: Aptos requires low-S (canonical) ECDSA signatures"
409+
.into(),
410+
));
411+
}
364412
Ok(Self { inner: signature })
365413
}
366414

@@ -550,6 +598,58 @@ mod tests {
550598
assert!(result.is_err());
551599
}
552600

601+
#[test]
602+
fn test_high_s_signature_rejected() {
603+
use k256::elliptic_curve::ops::Neg;
604+
605+
// Sign a message (produces low-S)
606+
let private_key = Secp256k1PrivateKey::generate();
607+
let signature = private_key.sign(b"test message");
608+
609+
// Construct high-S by negating the S component: S' = n - S
610+
let low_s_sig = k256::ecdsa::Signature::from_slice(&signature.to_bytes()).unwrap();
611+
let (r, s) = low_s_sig.split_scalars();
612+
let neg_s = s.neg();
613+
let high_s_sig = k256::ecdsa::Signature::from_scalars(r, neg_s).unwrap();
614+
// Confirm it really is high-S (normalize_s returns Some for high-S)
615+
assert!(
616+
high_s_sig.normalize_s().is_some(),
617+
"constructed signature should be high-S"
618+
);
619+
let high_s_bytes = high_s_sig.to_bytes();
620+
621+
// from_bytes should reject high-S
622+
let result = Secp256k1Signature::from_bytes(&high_s_bytes);
623+
assert!(result.is_err(), "high-S signature should be rejected");
624+
assert!(
625+
result
626+
.unwrap_err()
627+
.to_string()
628+
.contains("high-S signature rejected"),
629+
"error message should mention high-S rejection"
630+
);
631+
632+
// Verify should also reject high-S (defense-in-depth via inner field)
633+
let sig_with_high_s = Secp256k1Signature { inner: high_s_sig };
634+
let public_key = private_key.public_key();
635+
let result = public_key.verify(b"test message", &sig_with_high_s);
636+
assert!(result.is_err(), "verify should reject high-S signature");
637+
}
638+
639+
#[test]
640+
fn test_signing_always_produces_low_s() {
641+
// Run multiple iterations to increase confidence
642+
for _ in 0..20 {
643+
let private_key = Secp256k1PrivateKey::generate();
644+
let signature = private_key.sign(b"test low-s");
645+
// normalize_s returns None if already low-S
646+
assert!(
647+
signature.inner.normalize_s().is_none(),
648+
"signing must always produce low-S signatures"
649+
);
650+
}
651+
}
652+
553653
#[test]
554654
fn test_json_serialization_public_key() {
555655
let private_key = Secp256k1PrivateKey::generate();

crates/aptos-sdk/src/crypto/secp256r1.rs

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
//!
66
//! # Security: Signature Malleability
77
//!
8-
//! This implementation uses the `p256` crate which produces normalized (low-S)
9-
//! ECDSA signatures by default. This prevents signature malleability attacks
10-
//! where an attacker could create an alternative valid signature `(r, -s mod n)`
11-
//! for the same message.
8+
//! This implementation enforces **low-S only** signatures to match the Aptos
9+
//! blockchain's on-chain verification, which rejects high-S signatures to
10+
//! prevent signature malleability attacks.
1211
//!
13-
//! When parsing external signatures via [`Secp256r1Signature::from_bytes`], the
14-
//! `p256` crate accepts both low-S and high-S signatures. If strict low-S
15-
//! enforcement is required, callers should normalize or reject high-S signatures.
12+
//! - **Signing** always produces low-S signatures (the `p256` crate normalizes
13+
//! by default).
14+
//! - **Parsing** (`from_bytes`, `from_hex`) rejects high-S signatures.
15+
//! - **Verification** also rejects high-S signatures as a defense-in-depth
16+
//! measure.
1617
1718
use crate::crypto::traits::{PublicKey, Signature, Signer, Verifier};
1819
use crate::error::{AptosError, AptosResult};
@@ -123,17 +124,28 @@ impl Secp256r1PrivateKey {
123124
}
124125
}
125126

126-
/// Signs a message (pre-hashed with SHA256) and returns the signature.
127+
/// Signs a message (pre-hashed with SHA256) and returns a low-S signature.
128+
///
129+
/// The signature is normalized to low-S form to match Aptos on-chain
130+
/// verification requirements.
127131
pub fn sign(&self, message: &[u8]) -> Secp256r1Signature {
128132
let hash = crate::crypto::sha2_256(message);
129133
let signature: P256Signature = self.inner.sign(&hash);
130-
Secp256r1Signature { inner: signature }
134+
// SECURITY: Normalize to low-S to match Aptos on-chain verification.
135+
// The p256 crate does not guarantee low-S output from signing.
136+
let normalized = signature.normalize_s().unwrap_or(signature);
137+
Secp256r1Signature { inner: normalized }
131138
}
132139

133-
/// Signs a pre-hashed message directly.
140+
/// Signs a pre-hashed message directly and returns a low-S signature.
141+
///
142+
/// The signature is normalized to low-S form to match Aptos on-chain
143+
/// verification requirements.
134144
pub fn sign_prehashed(&self, hash: &[u8; 32]) -> Secp256r1Signature {
135145
let signature: P256Signature = self.inner.sign(hash);
136-
Secp256r1Signature { inner: signature }
146+
// SECURITY: Normalize to low-S to match Aptos on-chain verification.
147+
let normalized = signature.normalize_s().unwrap_or(signature);
148+
Secp256r1Signature { inner: normalized }
137149
}
138150
}
139151

@@ -233,10 +245,21 @@ impl Secp256r1PublicKey {
233245

234246
/// Verifies a signature against a message.
235247
///
248+
/// # Security
249+
///
250+
/// Rejects high-S signatures before verification, matching Aptos on-chain
251+
/// behavior. This is a defense-in-depth check; signatures created through
252+
/// this SDK's `from_bytes` are already guaranteed to be low-S.
253+
///
236254
/// # Errors
237255
///
238-
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature is invalid or does not match the message.
256+
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature has
257+
/// a high-S value, is invalid, or does not match the message.
239258
pub fn verify(&self, message: &[u8], signature: &Secp256r1Signature) -> AptosResult<()> {
259+
// SECURITY: Reject high-S signatures (matches aptos-core behavior)
260+
if signature.inner.normalize_s().is_some() {
261+
return Err(AptosError::SignatureVerificationFailed);
262+
}
240263
let hash = crate::crypto::sha2_256(message);
241264
self.inner
242265
.verify(&hash, &signature.inner)
@@ -245,14 +268,24 @@ impl Secp256r1PublicKey {
245268

246269
/// Verifies a signature against a pre-hashed message.
247270
///
271+
/// # Security
272+
///
273+
/// Rejects high-S signatures before verification, matching Aptos on-chain
274+
/// behavior.
275+
///
248276
/// # Errors
249277
///
250-
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature is invalid or does not match the hash.
278+
/// Returns [`AptosError::SignatureVerificationFailed`] if the signature has
279+
/// a high-S value, is invalid, or does not match the hash.
251280
pub fn verify_prehashed(
252281
&self,
253282
hash: &[u8; 32],
254283
signature: &Secp256r1Signature,
255284
) -> AptosResult<()> {
285+
// SECURITY: Reject high-S signatures (matches aptos-core behavior)
286+
if signature.inner.normalize_s().is_some() {
287+
return Err(AptosError::SignatureVerificationFailed);
288+
}
256289
self.inner
257290
.verify(hash, &signature.inner)
258291
.map_err(|_| AptosError::SignatureVerificationFailed)
@@ -344,11 +377,18 @@ pub struct Secp256r1Signature {
344377
impl Secp256r1Signature {
345378
/// Creates a signature from raw bytes (64 bytes, r || s).
346379
///
380+
/// # Security
381+
///
382+
/// Rejects high-S signatures to match Aptos on-chain verification behavior.
383+
/// The Aptos VM only accepts low-S (canonical) ECDSA signatures to prevent
384+
/// signature malleability attacks.
385+
///
347386
/// # Errors
348387
///
349388
/// Returns [`AptosError::InvalidSignature`] if:
350389
/// - The byte slice length is not exactly 64 bytes
351390
/// - The bytes do not represent a valid Secp256r1 signature
391+
/// - The signature has a high-S value (not canonical)
352392
pub fn from_bytes(bytes: &[u8]) -> AptosResult<Self> {
353393
if bytes.len() != SECP256R1_SIGNATURE_LENGTH {
354394
return Err(AptosError::InvalidSignature(format!(
@@ -359,6 +399,15 @@ impl Secp256r1Signature {
359399
}
360400
let signature = P256Signature::from_slice(bytes)
361401
.map_err(|e| AptosError::InvalidSignature(e.to_string()))?;
402+
// SECURITY: Reject high-S signatures. Aptos on-chain verification only
403+
// accepts low-S (canonical) signatures to prevent malleability.
404+
// normalize_s() returns Some(_) if the signature was high-S.
405+
if signature.normalize_s().is_some() {
406+
return Err(AptosError::InvalidSignature(
407+
"high-S signature rejected: Aptos requires low-S (canonical) ECDSA signatures"
408+
.into(),
409+
));
410+
}
362411
Ok(Self { inner: signature })
363412
}
364413

@@ -536,6 +585,58 @@ mod tests {
536585
assert!(result.is_err());
537586
}
538587

588+
#[test]
589+
fn test_high_s_signature_rejected() {
590+
use p256::elliptic_curve::ops::Neg;
591+
592+
// Sign a message (produces low-S after normalization)
593+
let private_key = Secp256r1PrivateKey::generate();
594+
let signature = private_key.sign(b"test message");
595+
596+
// Construct high-S by negating the S component: S' = n - S
597+
let low_s_sig = P256Signature::from_slice(&signature.to_bytes()).unwrap();
598+
let (r, s) = low_s_sig.split_scalars();
599+
let neg_s = s.neg();
600+
let high_s_sig = P256Signature::from_scalars(r, neg_s).unwrap();
601+
// Confirm it really is high-S (normalize_s returns Some for high-S)
602+
assert!(
603+
high_s_sig.normalize_s().is_some(),
604+
"constructed signature should be high-S"
605+
);
606+
let high_s_bytes = high_s_sig.to_bytes();
607+
608+
// from_bytes should reject high-S
609+
let result = Secp256r1Signature::from_bytes(&high_s_bytes);
610+
assert!(result.is_err(), "high-S signature should be rejected");
611+
assert!(
612+
result
613+
.unwrap_err()
614+
.to_string()
615+
.contains("high-S signature rejected"),
616+
"error message should mention high-S rejection"
617+
);
618+
619+
// Verify should also reject high-S (defense-in-depth via inner field)
620+
let sig_with_high_s = Secp256r1Signature { inner: high_s_sig };
621+
let public_key = private_key.public_key();
622+
let result = public_key.verify(b"test message", &sig_with_high_s);
623+
assert!(result.is_err(), "verify should reject high-S signature");
624+
}
625+
626+
#[test]
627+
fn test_signing_always_produces_low_s() {
628+
// Run multiple iterations to increase confidence
629+
for _ in 0..20 {
630+
let private_key = Secp256r1PrivateKey::generate();
631+
let signature = private_key.sign(b"test low-s");
632+
// normalize_s returns None if already low-S
633+
assert!(
634+
signature.inner.normalize_s().is_none(),
635+
"signing must always produce low-S signatures"
636+
);
637+
}
638+
}
639+
539640
#[test]
540641
fn test_json_serialization_public_key() {
541642
let private_key = Secp256r1PrivateKey::generate();

0 commit comments

Comments
 (0)