@@ -1114,6 +1114,83 @@ def consider_outputs(self):
11141114 self .warnings .append (('Big Fee' , 'Network fee is more than '
11151115 '5%% of total value (%.1f%%).' % per_fee ))
11161116
1117+ # Enforce policy related to change outputs
1118+ self .consider_dangerous_change (self .my_xfp )
1119+
1120+ def consider_dangerous_change (self , my_xfp ):
1121+ # Enforce some policy on change outputs:
1122+ # - need to "look like" they are going to same wallet as inputs came from
1123+ # - range limit last two path components (numerically)
1124+ # - same pattern of hard/not hardened components
1125+ # - MAX_PATH_DEPTH already enforced before this point
1126+ #
1127+ in_paths = []
1128+ for inp in self .inputs :
1129+ if inp .fully_signed : continue
1130+ if not inp .required_key : continue
1131+ if not inp .subpaths : continue # not expected if we're signing it
1132+ for path in inp .subpaths .values ():
1133+ if path [0 ] == my_xfp :
1134+ in_paths .append (path [1 :])
1135+
1136+ if not in_paths :
1137+ # We aren't adding any signatures? Can happen but we're going to be
1138+ # showing a warning about that elsewhere.
1139+ return
1140+
1141+ shortest = min (len (i ) for i in in_paths )
1142+ longest = max (len (i ) for i in in_paths )
1143+ if shortest != longest or shortest <= 2 :
1144+ # We aren't seeing common input path lengths.
1145+ # They are probbably doing weird stuff, so leave them alone.
1146+ # (Remember: we are trusting the input side of things here)
1147+ return
1148+
1149+ # Assumption: hard/not hardened depths will match for all address in wallet
1150+ def hard_bits (p ):
1151+ return [bool (i & 0x80000000 ) for i in p ]
1152+
1153+ # Assumption: common wallets modulate the last two components only
1154+ # of the path. Typically m/.../change/index where change is {0, 1}
1155+ # and index changes slowly over lifetime of wallet (increasing)
1156+ path_len = shortest
1157+ path_prefix = in_paths [0 ][0 :- 2 ]
1158+ idx_max = max (i [- 1 ]& 0x7fffffff for i in in_paths ) + 200
1159+ hard_pattern = hard_bits (in_paths [0 ])
1160+
1161+ probs = []
1162+ for nout , out in enumerate (self .outputs ):
1163+ if not out .is_change : continue
1164+ # it's a change output, okay if a p2sh change; we're looking at paths
1165+ for path in inp .subpaths .values ():
1166+ if path [0 ] != my_xfp : continue # possible in p2sh case
1167+
1168+ path = path [1 :]
1169+ if len (path ) != path_len :
1170+ iss = "has wrong path length (%d not %d)" % (len (path ), path_len )
1171+ elif hard_bits (path ) != hard_pattern :
1172+ iss = "has different hard/not hardened pattern"
1173+ elif path [0 :len (path_prefix )] != path_prefix :
1174+ iss = "goes to diff path prefix"
1175+ elif (path [- 2 ]& 0x7fffffff ) not in {0 , 1 }:
1176+ iss = "2nd last component not 0 or 1"
1177+ elif (path [- 1 ]& 0x7fffffff ) > idx_max :
1178+ iss = "last component beyond idx+200 of inputs"
1179+ else :
1180+ # looks ok
1181+ continue
1182+
1183+ probs .append ("Output#%d: %s: %s not %s/{0~1}%s/{0~%d}%s expected"
1184+ % (nout , iss , path_to_str (path , skip = 0 ),
1185+ path_to_str (path_prefix , skip = 0 ),
1186+ "'" if hard_pattern [- 2 ] else "" ,
1187+ idx_max , "'" if hard_pattern [- 1 ] else "" ,
1188+ ))
1189+ break
1190+
1191+ for p in probs :
1192+ self .warnings .append (('Troublesome Change Outs' , p ))
1193+
11171194 def consider_inputs (self ):
11181195 # Look an the UTXO's that we are spending. Do we have them? Do the
11191196 # hashes match, and what values are we getting?
0 commit comments