Skip to content
Merged
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
152 changes: 143 additions & 9 deletions src/attack/nonce_reuse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use crate::math::{
recover_nonce, recover_private_key, scalar_to_decimal_string, scalar_to_hex_string,
};
use crate::signature::group_by_r_and_pubkey;
use k256::elliptic_curve::sec1::ToEncodedPoint;
use k256::{AffinePoint, ProjectivePoint};

pub struct NonceReuseAttack;

Expand Down Expand Up @@ -50,15 +52,47 @@ fn try_recover_pair(
sig2: &Signature,
pubkey: &Option<String>,
) -> Option<RecoveredKey> {
let k = recover_nonce(&sig1.z, &sig2.z, &sig1.s, &sig2.s)?;
let priv_key = recover_private_key(&sig1.r, &sig1.s, &sig1.z, &k)?;

Some(RecoveredKey {
private_key: priv_key,
private_key_decimal: scalar_to_decimal_string(&priv_key),
private_key_hex: scalar_to_hex_string(&priv_key),
pubkey: pubkey.clone(),
})
if pubkey.is_some() {
// With pubkey: try both s2 polarities and verify d*G == pubkey.
// Handles mixed low-s/high-s normalization (BIP62).
for &s2 in &[sig2.s, -sig2.s] {
let k = match recover_nonce(&sig1.z, &sig2.z, &sig1.s, &s2) {
Some(k) => k,
None => continue,
};
let priv_key = match recover_private_key(&sig1.r, &sig1.s, &sig1.z, &k) {
Some(d) => d,
None => continue,
};
if verify_key_matches_pubkey(&priv_key, pubkey.as_ref().unwrap()) {
return Some(RecoveredKey {
private_key: priv_key,
private_key_decimal: scalar_to_decimal_string(&priv_key),
private_key_hex: scalar_to_hex_string(&priv_key),
pubkey: pubkey.clone(),
});
}
}
None
} else {
let k = recover_nonce(&sig1.z, &sig2.z, &sig1.s, &sig2.s)?;
let priv_key = recover_private_key(&sig1.r, &sig1.s, &sig1.z, &k)?;
Some(RecoveredKey {
private_key: priv_key,
private_key_decimal: scalar_to_decimal_string(&priv_key),
private_key_hex: scalar_to_hex_string(&priv_key),
pubkey: pubkey.clone(),
})
}
}

fn verify_key_matches_pubkey(d: &Scalar, pubkey: &str) -> bool {
let computed = ProjectivePoint::GENERATOR * *d;
let affine: AffinePoint = computed.into();
let compressed = hex::encode(affine.to_encoded_point(true).as_bytes());
let uncompressed = hex::encode(affine.to_encoded_point(false).as_bytes());
let pk = pubkey.to_lowercase();
Comment thread
oritwoen marked this conversation as resolved.
pk == compressed || pk == uncompressed
}

#[cfg(test)]
Expand Down Expand Up @@ -116,6 +150,106 @@ mod tests {
assert_eq!(recovered.private_key_decimal, expected);
}

#[test]
fn test_nonce_reuse_recovery_with_pubkey_verification() {
use k256::elliptic_curve::ff::PrimeField;

let d = Scalar::from(42u64);
let pubkey_point = (ProjectivePoint::GENERATOR * d).to_affine();
let pubkey_hex = hex::encode(pubkey_point.to_encoded_point(true).as_bytes());

let k = Scalar::from(1000u64);
let z1 = Scalar::from(111u64);
let z2 = Scalar::from(222u64);

let kg = ProjectivePoint::GENERATOR * k;
let r = Scalar::from_repr_vartime(*kg.to_affine().to_encoded_point(false).x().unwrap())
.unwrap();

let k_inv = Option::<Scalar>::from(k.invert()).unwrap();
let s1 = k_inv * (z1 + r * d);
let s2 = k_inv * (z2 + r * d);

let sigs = vec![
Signature {
r,
s: s1,
z: z1,
pubkey: Some(pubkey_hex.clone()),
timestamp: None,
kp: None,
},
Signature {
r,
s: s2,
z: z2,
pubkey: Some(pubkey_hex.clone()),
timestamp: None,
kp: None,
},
];

let attack = NonceReuseAttack;
let vulns = attack.detect(&sigs);
assert_eq!(vulns.len(), 1);

let recovered = attack.recover(&vulns[0]).unwrap();
assert_eq!(recovered.private_key, d);
}

#[test]
fn test_nonce_reuse_recovery_negated_s_with_pubkey() {
use k256::elliptic_curve::ff::PrimeField;

let d = Scalar::from(42u64);
let pubkey_point = (ProjectivePoint::GENERATOR * d).to_affine();
let pubkey_hex = hex::encode(pubkey_point.to_encoded_point(true).as_bytes());

let k = Scalar::from(1000u64);
let z1 = Scalar::from(111u64);
let z2 = Scalar::from(222u64);

let kg = ProjectivePoint::GENERATOR * k;
let r = Scalar::from_repr_vartime(*kg.to_affine().to_encoded_point(false).x().unwrap())
.unwrap();

let k_inv = Option::<Scalar>::from(k.invert()).unwrap();
let s1 = k_inv * (z1 + r * d);
let s2_raw = k_inv * (z2 + r * d);
// Negate s2 to simulate mixed low-s/high-s normalization (BIP62)
let s2_negated = -s2_raw;

let sigs = vec![
Signature {
r,
s: s1,
z: z1,
pubkey: Some(pubkey_hex.clone()),
timestamp: None,
kp: None,
},
Signature {
r,
s: s2_negated,
z: z2,
pubkey: Some(pubkey_hex.clone()),
timestamp: None,
kp: None,
},
];

let attack = NonceReuseAttack;
let vulns = attack.detect(&sigs);
assert_eq!(vulns.len(), 1);

let recovered = attack.recover(&vulns[0]);
assert!(
recovered.is_some(),
"Should recover key even with negated s when pubkey is available"
);
assert_eq!(recovered.unwrap().private_key, d);
}

#[test]
fn test_no_false_positives_different_r() {
let sigs = vec![
Expand Down