11#!/usr/bin/env python
2- # Author: Dario Clavijo (2020)
3- # MIT License
2+ # Author: Dario Clavijo (2020)
3+ # MIT License
44
55import sys
66import argparse
77import mmap
8+ import heapq
89import gmpy2
910from fpylll import IntegerMatrix , LLL , BKZ
1011from ecdsa import SigningKey , SECP256k1
11- from ecdsa .ecdsa import int_to_string
1212
1313DEFAULT_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
1414
@@ -29,6 +29,7 @@ def normalize_pubhex(s: str) -> str:
2929
3030def load_csv (filename , limit = None , mmap_flag = False ):
3131 msgs , sigs , pubs = [], [], []
32+
3233 def parse_pub (p ):
3334 return normalize_pubhex (p )
3435
@@ -39,10 +40,10 @@ def parse_pub(p):
3940 for n , line in enumerate (lines ):
4041 if limit is not None and n >= limit :
4142 break
42- l = line .decode ("utf-8" ).rstrip ().split ("," )
43- if len (l ) < 5 :
43+ parts = line .decode ("utf-8" ).rstrip ().split ("," )
44+ if len (parts ) < 5 :
4445 continue
45- tx , R , S , Z , pub = l [:5 ]
46+ tx , R , S , Z , pub = parts [:5 ]
4647 msgs .append (int (Z , 16 ))
4748 sigs .append ((int (R , 16 ), int (S , 16 )))
4849 pubs .append (parse_pub (pub ))
@@ -127,11 +128,64 @@ def point_bytes_from_vk(vk):
127128 return uncompressed .lower (), compressed .lower ()
128129
129130
130- def privkeys_from_reduced_matrix (msgs , sigs , pubs , matrix , order , max_rows = 20 , max_candidates = 1000 ):
131+ def _compute_row_norms (matrix , m , max_rows ):
132+ """Compute norms for matrix rows, returning up to max_rows smallest.
133+
134+ Args:
135+ matrix: The reduced lattice matrix
136+ m: Number of columns to include in norm computation
137+ max_rows: Maximum number of rows to return
138+
139+ Returns:
140+ List of (norm, row_index) tuples, sorted by norm (smallest first).
141+ Returns min(matrix.nrows, max_rows) items.
142+
143+ Uses heapq.nsmallest for efficient partial sorting when only a subset
144+ of rows is needed. Falls back to full sort when all rows are needed.
145+ """
146+ norms_generator = (
147+ (sum (float (matrix [ridx , j ]) ** 2 for j in range (m )), ridx )
148+ for ridx in range (matrix .nrows )
149+ )
150+
151+ if matrix .nrows <= max_rows :
152+ # Need all rows (fewer than max_rows exist) - full sort is fine
153+ return sorted (norms_generator )
154+ else :
155+ # Need only top max_rows out of many - heapq is more efficient
156+ return heapq .nsmallest (max_rows , norms_generator )
157+
158+
159+ def _extract_candidate_from_row (row_val , b , cd , ab_list , order ):
160+ """Extract private key candidates from a single row value."""
161+ candidates = []
162+ base = (cd - (b * row_val )) % order
163+
164+ if ab_list is None :
165+ candidate = base % order
166+ if 1 <= candidate < order :
167+ candidates .append (candidate )
168+ else :
169+ for ab in ab_list :
170+ if ab :
171+ try :
172+ inv = modular_inv (ab , order )
173+ candidate = (base * inv ) % order
174+ if 1 <= candidate < order :
175+ candidates .append (candidate )
176+ except Exception :
177+ pass
178+ return candidates
179+
180+
181+ def privkeys_from_reduced_matrix (
182+ msgs , sigs , pubs , matrix , order , max_rows = 20 , max_candidates = 1000
183+ ):
131184 keys = set ()
132185 m = len (msgs )
133186 msgn , rn , sn = msgs [- 1 ], sigs [- 1 ][0 ], sigs [- 1 ][1 ]
134187
188+ # Precompute parameters for each signature
135189 params = []
136190 for i in range (m ):
137191 a = (rn * sigs [i ][1 ]) % order
@@ -142,76 +196,74 @@ def privkeys_from_reduced_matrix(msgs, sigs, pubs, matrix, order, max_rows=20, m
142196 ab_list = None if a == b else [((a - b ) % order ), ((b - a ) % order )]
143197 params .append ((b , cd , ab_list ))
144198
145- # compute row norms (only first m columns contribute to norm in our design)
146- row_norms = []
147- for ridx in range (matrix .nrows ):
148- # norm over first m columns
149- norm2 = 0.0
150- for j in range (m ):
151- v = float (matrix [ridx , j ])
152- norm2 += v * v
153- row_norms .append ((norm2 , ridx ))
154- row_norms .sort ()
155-
156- checked = 0
157- for _ , ridx in row_norms [:max_rows ]:
158- if checked >= max_candidates :
199+ # Get sorted row indices by norm
200+ row_norms = _compute_row_norms (matrix , m , max_rows )
201+
202+ # Extract candidates from best rows
203+ for _ , ridx in row_norms :
204+ if len (keys ) >= max_candidates :
159205 break
160206 row = [int (matrix [ridx , j ]) for j in range (m )]
161207 for i , (b , cd , ab_list ) in enumerate (params ):
162- base = (cd - (b * row [i ])) % order
163- if ab_list is None :
164- # direct candidate
165- candidate = base % order
166- if 1 <= candidate < order :
167- keys .add (candidate )
168- else :
169- for ab in ab_list :
170- if ab :
171- try :
172- inv = modular_inv (ab , order )
173- except Exception :
174- continue
175- candidate = (base * inv ) % order
176- if 1 <= candidate < order :
177- keys .add (candidate )
178- checked = len (keys )
179- if checked >= max_candidates :
180- break
208+ candidates = _extract_candidate_from_row (row [i ], b , cd , ab_list , order )
209+ keys .update (candidates )
210+ if len (keys ) >= max_candidates :
211+ break
181212
182213 return list (keys )
183214
184215
185- def derived_pubhexes_for_candidates (candidates ):
186- """Given a list of private integer candidates, derive both compressed and uncompressed pub hexes."""
187- mapping = {}
188- for priv in candidates :
189- try :
190- sk = SigningKey .from_secret_exponent (priv , curve = SECP256k1 )
191- vk = sk .get_verifying_key ()
192- uncmp , cmpd = point_bytes_from_vk (vk )
193- mapping [priv ] = (uncmp , cmpd )
194- except Exception :
195- # skip invalid privs
196- continue
197- return mapping
216+ def _try_derive_pubkey (priv ):
217+ """Attempt to derive public key from private key."""
218+ try :
219+ sk = SigningKey .from_secret_exponent (priv , curve = SECP256k1 )
220+ vk = sk .get_verifying_key ()
221+ return point_bytes_from_vk (vk )
222+ except Exception :
223+ return None , None
224+
225+
226+ def _verify_candidates (keys , pubset , find_all = False ):
227+ """Verify candidate private keys against known public keys.
228+
229+ Args:
230+ keys: List of candidate private keys to verify
231+ pubset: Set of known public keys (hex strings)
232+ find_all: If True, find all matching keys. If False, stop after
233+ first match (optimization for typical use case)
234+
235+ Returns:
236+ List of tuples (priv, uncompressed_pub, compressed_pub)
237+ """
238+ verified = []
239+ for priv in keys :
240+ uncmp , cmpd = _try_derive_pubkey (priv )
241+ if uncmp and (uncmp in pubset or cmpd in pubset ):
242+ verified .append ((priv , uncmp , cmpd ))
243+ if not find_all :
244+ break # Early exit optimization after first match
245+ return verified
198246
199247
200248def display_keys (keys , pubkeys , show_all = False ):
249+ """Display verified private keys or all candidates.
250+
251+ Args:
252+ keys: List of candidate private keys
253+ pubkeys: List of known public keys for verification
254+ show_all: If True, show all candidates when none verify. Also finds
255+ all matching keys instead of stopping at first match.
256+ """
201257 pubset = set (normalize_pubhex (p ) for p in pubkeys if p )
202258 if not pubset :
203259 sys .stderr .write ("[!] Warning: no pubkeys given for verification.\n " )
204260
205- # batch derive pubkeys
206- mapping = derived_pubhexes_for_candidates (keys )
207261 verified = []
208- for priv , (uncmp , cmpd ) in mapping .items ():
209- if uncmp in pubset or cmpd in pubset :
210- verified .append ((priv , uncmp , cmpd ))
262+ if pubset :
263+ verified = _verify_candidates (keys , pubset , find_all = show_all )
211264
212265 if not verified :
213266 if show_all :
214- # show all candidates in hex
215267 print ("Recovered candidates (not verified):" )
216268 for k in keys :
217269 print (f"{ k :064x} " )
@@ -227,30 +279,83 @@ def display_keys(keys, pubkeys, show_all=False):
227279
228280
229281def main ():
230- parser = argparse .ArgumentParser (description = "ECDSA private key recovery using lattice reduction (fpylll)" )
282+ parser = argparse .ArgumentParser (
283+ description = "ECDSA private key recovery using lattice reduction"
284+ )
231285 parser .add_argument ("filename" , help = "CSV file containing ECDSA traces" )
232286 parser .add_argument ("B" , type = int , help = "log2 bound parameter B" )
233- parser .add_argument ("limit" , type = int , nargs = "?" , default = None , help = "Limit number of signatures to process (optional)" )
234- parser .add_argument ("--order" , type = int , default = DEFAULT_ORDER , help = "Curve order (default: secp256k1)" )
235- parser .add_argument ("--reduction" , choices = ["LLL" , "BKZ" ], default = "LLL" , help = "Lattice reduction algorithm" )
236- parser .add_argument ("--mmap" , action = "store_true" , help = "Enable mmap for fast CSV access" )
237- parser .add_argument ("--integer_mode" , action = "store_true" , help = "Scale matrix to ensure integer values" )
238- parser .add_argument ("--max_rows" , type = int , default = 20 , help = "Max number of reduced rows to inspect" )
239- parser .add_argument ("--max_candidates" , type = int , default = 1000 , help = "Stop after this many candidates are gathered" )
240- parser .add_argument ("--show_all" , action = "store_true" , help = "Show all recovered candidates even if not verified" )
287+ parser .add_argument (
288+ "limit" ,
289+ type = int ,
290+ nargs = "?" ,
291+ default = None ,
292+ help = "Limit number of signatures to process (optional)" ,
293+ )
294+ parser .add_argument (
295+ "--order" ,
296+ type = int ,
297+ default = DEFAULT_ORDER ,
298+ help = "Curve order (default: secp256k1)" ,
299+ )
300+ parser .add_argument (
301+ "--reduction" ,
302+ choices = ["LLL" , "BKZ" ],
303+ default = "LLL" ,
304+ help = "Lattice reduction algorithm" ,
305+ )
306+ parser .add_argument (
307+ "--mmap" , action = "store_true" , help = "Enable mmap for fast CSV access"
308+ )
309+ parser .add_argument (
310+ "--integer_mode" ,
311+ action = "store_true" ,
312+ help = "Scale matrix to ensure integer values" ,
313+ )
314+ parser .add_argument (
315+ "--max_rows" ,
316+ type = int ,
317+ default = 20 ,
318+ help = "Max number of reduced rows to inspect" ,
319+ )
320+ parser .add_argument (
321+ "--max_candidates" ,
322+ type = int ,
323+ default = 1000 ,
324+ help = "Stop after this many candidates are gathered" ,
325+ )
326+ parser .add_argument (
327+ "--show_all" ,
328+ action = "store_true" ,
329+ help = "Show all recovered candidates even if not verified" ,
330+ )
241331 args = parser .parse_args ()
242332
243- msgs , sigs , pubs = load_csv (args .filename , limit = args .limit , mmap_flag = args .mmap )
333+ msgs , sigs , pubs = load_csv (
334+ args .filename , limit = args .limit , mmap_flag = args .mmap
335+ )
244336 sys .stderr .write (f"Using: { len (msgs )} sigs...\n " )
245337 if len (msgs ) < 2 :
246- sys .stderr .write ("[!] Warning: fewer than 2 signatures - results may be meaningless.\n " )
247-
248- matrix = make_matrix_fpylll (msgs , sigs , args .B , args .order , integer_mode = args .integer_mode )
338+ sys .stderr .write (
339+ "[!] Warning: fewer than 2 signatures - "
340+ "results may be meaningless.\n "
341+ )
342+
343+ matrix = make_matrix_fpylll (
344+ msgs , sigs , args .B , args .order , integer_mode = args .integer_mode
345+ )
249346 sys .stderr .write ("Matrix constructed, starting reduction...\n " )
250347 matrix = reduce_matrix (matrix , algorithm = args .reduction )
251348 sys .stderr .write ("Reduction complete, extracting candidates...\n " )
252349
253- keys = privkeys_from_reduced_matrix (msgs , sigs , pubs , matrix , args .order , max_rows = args .max_rows , max_candidates = args .max_candidates )
350+ keys = privkeys_from_reduced_matrix (
351+ msgs ,
352+ sigs ,
353+ pubs ,
354+ matrix ,
355+ args .order ,
356+ max_rows = args .max_rows ,
357+ max_candidates = args .max_candidates ,
358+ )
254359 sys .stderr .write (f"Candidates found: { len (keys )} \n " )
255360
256361 display_keys (keys , pubs , show_all = args .show_all )
0 commit comments