def decode_point(self, serialized_point): assert (isinstance(serialized_point, bytes)) if len(serialized_point) != self.element_octet_cnt: raise InvalidInputException( "Do not know how to decode %s point. Expected %d octets, but got %d." % (str(self), self.element_octet_cnt, len(serialized_point))) serialized_point = bytearray(serialized_point) x_lsb = (serialized_point[-1] >> 7) & 1 serialized_point[-1] &= 0x7f y = int.from_bytes(serialized_point, byteorder="little") if y >= self.p: raise InvalidInputException( "y coordinate of point must be smaller than p.") # x^2 = (1 - y^2) / (a - dy^2) x2 = (1 - y * y) % self.p x2 *= NumberTheory.modinv(self.a - self.d * y * y, self.p) (x_pos, x_neg) = NumberTheory.sqrt_mod_p(x2, self.p) if x_lsb == 0: x = x_pos else: x = x_neg point = self.point(x, y) return point
def test_possible_divisors(self): self.assertEqual( list(sorted(NumberTheory.possible_divisors([2, 2, 3]))), [1, 2, 3, 4, 6, 12]) self.assertEqual( list(sorted(NumberTheory.possible_divisors([2, 3, 5]))), [1, 2, 3, 5, 6, 10, 15, 30])
def point_addition(self, P, Q): x = (P.x * Q.y + Q.x * P.y) % self.p x = (x * NumberTheory.modinv(1 + self.d * P.x * Q.x * P.y * Q.y, self.p)) % self.p y = (P.y * Q.y - self.a * P.x * Q.x) % self.p y = (y * NumberTheory.modinv(1 - self.d * P.x * Q.x * P.y * Q.y, self.p)) % self.p return self.point(x, y)
def test_hweight_margin(self): r = int.from_bytes(os.urandom(16), byteorder="little") self.assertTrue( NumberTheory.hamming_weight_analysis(r).plausibly_random) n = 0x10000000000000 self.assertFalse( NumberTheory.hamming_weight_analysis(n).plausibly_random)
def test_gen_prime(self): primes = set() for _ in range(15): p = NumberTheory.randprime_bits(6, two_msb_set=True) q = NumberTheory.randprime_bits(6, two_msb_set=True) primes.add(p) primes.add(q) n = p * q self.assertEqual(n.bit_length(), 12) # With reasonal probability, all will be hit self.assertEqual(primes, set([53, 59, 61]))
def test_egcd(self): (g, s, t) = NumberTheory.egcd(4567, 123) self.assertEqual(g, 1) self.assertEqual(s, -23) self.assertEqual(t, 854) (g, s, t) = NumberTheory.egcd(123, 4567) self.assertEqual(g, 1) self.assertEqual(s, 854) self.assertEqual(t, -23) (g, s, t) = NumberTheory.egcd(101 * 17, 101 * 11) self.assertEqual(g, 101) self.assertEqual(s, 2) self.assertEqual(t, -3)
def test_carryless_multiplication(self): for x in [0, 1, 2, 3, 4, 5, 123, 267, 498327849327]: self.assertEqual(NumberTheory.cl_mul(0, x), 0) self.assertEqual(NumberTheory.cl_mul(x, 0), 0) self.assertEqual(NumberTheory.cl_mul(1, x), x) self.assertEqual(NumberTheory.cl_mul(x, 1), x) self.assertEqual(NumberTheory.cl_mul(2, x), 2 * x) self.assertEqual(NumberTheory.cl_mul(x, 2), 2 * x) self.assertEqual(NumberTheory.cl_mul(x, 1024), 1024 * x) self.assertEqual(NumberTheory.cl_mul(1024, x), 1024 * x) self.assertEqual(NumberTheory.cl_mul(x, 1 | 2 | 8 | 256), (1 * x) ^ (2 * x) ^ (8 * x) ^ (256 * x)) self.assertEqual(NumberTheory.cl_mul(1 | 2 | 8 | 256, x), (1 * x) ^ (2 * x) ^ (8 * x) ^ (256 * x))
def check_integrity(self, msg=12345678987654321): # Assert gcd(e, phi(n)) == 1 phi_n = (self.p - 1) * (self.q - 1) gcd = NumberTheory.gcd(phi_n, self.e) if gcd != 1: raise KeyCorruptException( "Expected gcd(phi(n), e) to be 1, but was %d." % (gcd)) # Truncate msg if too large for exponent msg = msg % self.n # Calculate normale signature and verify sig = pow(msg, self.d, self.n) verify = pow(sig, self.e, self.n) if verify != msg: raise KeyCorruptException("Expected verify value %d, but got %d." % (msg, verify)) # Test that RSA-CRT constants work m1 = pow(msg, self.dmp1, self.p) m2 = pow(msg, self.dmq1, self.q) sig_crt = (((self.iqmp * (m1 - m2)) % self.p) * self.q) + m2 if sig != sig_crt: raise KeyCorruptException( "Expected same signature for naive signature as RSA-CRT signature, but former was 0x%x and latter 0x%x." % (sig, sig_crt))
def _judge_curve_embedding_degree(self, curve): d = 1 for k in range(1, 50 + 1): d = (d * curve.p) % curve.n if d == 1: if (k == 1) or (NumberTheory.is_probable_prime(curve.n)): fail_text = "k = %d" % (k) else: fail_text = "k <= %d" % (k) literature = LiteratureReference( author=[ "Alfred Menezes", "Scott Vanstone", "Tatsuaki Okamoto" ], title= "Reducing Elliptic Curve Logarithms to Logarithms in a Finite Field", year=1991, source="ACM") return SecurityJudgement( JudgementCode. X509Cert_PublicKey_ECC_DomainParameters_CurveProperty_LowEmbeddingDegree, "This curve has low embedding degree (%s), it fails the MOV condition. It can be compromised using the probabilistic polynomial-time MOV attack." % (fail_text), bits=0, commonness=Commonness.HIGHLY_UNUSUAL, literature=literature) return None
def _judge_curve_cofactor(self, curve): judgements = SecurityJudgements() if curve.h is None: judgements += SecurityJudgement( JudgementCode. X509Cert_PublicKey_ECC_DomainParameters_Cofactor_Missing, "Curve cofactor h is not present in explicit domain parameter encoding. This is allowed, but highly unusual.", commonness=Commonness.HIGHLY_UNUSUAL) else: if curve.h <= 0: judgements += SecurityJudgement( JudgementCode. X509Cert_PublicKey_ECC_DomainParameters_CurveProperty_Cofactor_Invalid, "Curve cofactor h = %d is zero or negative. This is invalid." % (curve.h), bits=0, commonness=Commonness.HIGHLY_UNUSUAL) elif curve.h > 8: judgements += SecurityJudgement( JudgementCode. X509Cert_PublicKey_ECC_DomainParameters_CurveProperty_Cofactor_Large, "Curve cofactor is unusually large, h = %d. This is an indication the curve has non-ideal cryptographic properties; would expect h <= 8." % (curve.h), commonness=Commonness.HIGHLY_UNUSUAL) if curve.curvetype == "prime": field_size = curve.p elif curve.curvetype == "binary": # TODO: For GF(p), the number of group elements is simply p. # For GF(2^m), I'm fairly certiain it is the reduction # polynomial (essentially, it's modular polynomial arithmetic). # Not 100% sure though. Verify. field_size = curve.int_poly # Hasse Theorem on Elliptic Curves: # p - (2 * sqrt(p)) + 1 <= #E(F_p) <= p + (2 * sqrt(p)) + 1 # #E(F_p) = n h # h >= (p - (2 * sqrt(p)) + 1) / n # h <= (p + (2 * sqrt(p)) + 1) / n sqrt_p = NumberTheory.isqrt(field_size) hasse_h_min = (field_size - (2 * (sqrt_p + 1)) + 1) // curve.n hasse_h_max = (field_size + (2 * (sqrt_p + 1)) + 1) // curve.n if not (hasse_h_min <= curve.h <= hasse_h_max): literature = LiteratureReference( author="Helmut Hasse", title= "Zur Theorie der abstrakten elliptischen Funktionenkörper. I, II & III", year=1936, source="Crelle's Journal") judgements += SecurityJudgement( JudgementCode. X509Cert_PublicKey_ECC_DomainParameters_CurveProperty_Cofactor_OutsideHasseBound, "Curve cofactor h = %d is outside the Hasse bound (%d <= h <= %d). Cofactor therefore is invalid." % (curve.h, hasse_h_min, hasse_h_max), bits=0, commonness=Commonness.HIGHLY_UNUSUAL, literature=literature) return judgements
def on_curve(self, point): lhs = NumberTheory.binpoly_reduce( NumberTheory.cl_mul(point.y, point.y) ^ NumberTheory.cl_mul(point.x, point.y), self.intpoly) rhs = NumberTheory.binpoly_reduce( NumberTheory.cl_mul(NumberTheory.cl_mul(point.x, point.x), point.x) ^ NumberTheory.cl_mul(NumberTheory.cl_mul(self.a, point.x), point.x) ^ self.b, self.intpoly) return lhs == rhs
def test_iterprimes(self): primes = [ value for (no, value) in zip(range(25), NumberTheory.iter_primes()) ] self.assertEqual(primes, [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97 ])
def test_probable_prime(self): self.assertTrue(NumberTheory.is_probable_prime(2)) self.assertTrue(NumberTheory.is_probable_prime(3)) self.assertTrue(NumberTheory.is_probable_prime(5)) self.assertTrue(NumberTheory.is_probable_prime(101)) self.assertFalse(NumberTheory.is_probable_prime(0)) self.assertFalse(NumberTheory.is_probable_prime(1)) self.assertFalse(NumberTheory.is_probable_prime(4))
def test_sqrt_5_mod_8(self): for p in [11948800825345174421]: self.assertEqual(p % 8, 5) for value in [1, 123, 123456789, 0x123456789]: value = value % p sqr = (value * value) % p (sqrt_pos, sqrt_neg) = NumberTheory.sqrt_mod_p(sqr, p) self.assertEqual((sqrt_pos & 1), 0) self.assertEqual((sqrt_neg & 1), 1) self.assertEqual((sqrt_pos * sqrt_pos) % p, sqr)
def _select_q(self, p): if not self._args.close_q: while True: yield self._prime_db.get(bitlen=self._q_bitlen, primetype=self._primetype) else: q = p while True: q += 2 * random.randint(1, self._args.q_stepping) if NumberTheory.is_probable_prime(q): yield q
def __init__(self, cmdname, args): BaseAction.__init__(self, cmdname, args) if (not self._args.force) and os.path.exists(self._args.outfile): raise UnfulfilledPrerequisitesException( "File/directory %s already exists. Remove it first or use --force." % (self._args.outfile)) self._prime_db = PrimeDB(self._args.prime_db, generator_program=self._args.generator) q = self._prime_db.get(args.N_bits) if self._args.verbose >= 1: print("Chosen q = 0x%x" % (q)) bit_diff = args.L_bits - q.bit_length() while True: r = NumberTheory.randint_bits(bit_diff, two_msb_set=True) p = (r * q) + 1 if NumberTheory.is_probable_prime(p): break if self._args.verbose >= 1: print("Chosen p = 0x%x" % (p)) assert (q.bit_length() == args.N_bits) assert (p.bit_length() == args.L_bits) assert ((p - 1) % q == 0) # Non-verifiable method of generating g, see A.2.1 of FIPS 186-4, pg. 41 e = (p - 1) // q while True: h = random.randint(2, p - 2) g = pow(h, e, p) if g == 1: continue break if self._args.verbose >= 1: print("Chosen g = 0x%x" % (g)) dsa_parameters = DSAParameters.create(p=p, q=q, g=g) dsa_parameters.write_pemfile(self._args.outfile)
def test_sqrt_3_mod_4(self): p = 9937818373633759003 self.assertEqual(p % 4, 3) for value in [1, 123, 123456789, 0x123456789]: value = value % p sqr = (value * value) % p (sqrt_pos, sqrt_neg) = NumberTheory.sqrt_mod_p(sqr, p) self.assertEqual((sqrt_pos & 1), 0) self.assertEqual((sqrt_neg & 1), 1) self.assertEqual((sqrt_pos * sqrt_pos) % p, sqr) self.assertEqual((sqrt_neg * sqrt_neg) % p, sqr)
def point_addition(self, P, Q): if P is None: # O + Q = Q return Q elif Q is None: # P + O = P return P elif ((P.x == Q.x) and (((P.y + Q.y) % self.p) == 0)): # P + (-P) = O return None elif P == Q: # Point doubling s = ((3 * P.x**2) + self.a) * NumberTheory.modinv(2 * P.y, self.p) x = (s * s - (2 * P.x)) % self.p y = (s * (P.x - x) - P.y) % self.p return EllipticCurvePoint(self, x, y) else: # Point addition s = (P.y - Q.y) * NumberTheory.modinv(P.x - Q.x, self.p) x = ((s**2) - P.x - Q.x) % self.p y = (s * (P.x - x) - P.y) % self.p return EllipticCurvePoint(self, x, y)
def _judge_binary_field_curve(self, curve): judgements = SecurityJudgements() literature = LiteratureReference( author=["Steven D. Galbraith", "Shishay W. Gebregiyorgis"], title= "Summation polynomial algorithms for elliptic curves in characteristic two", year=2014, source="Progress in Cryptology -- INDOCRYPT 2014; LNCS 8885") judgements += SecurityJudgement( JudgementCode.X509Cert_PublicKey_ECC_DomainParameters_BinaryField, "Binary finite field elliptic curve is used. Recent advances in cryptography show there might be efficient attacks on such curves, hence it is recommended to use prime-field curves instead.", commonness=Commonness.UNUSUAL, literature=literature) EFpm = curve.n * curve.h if (EFpm % 2) == 1: # Supersingular: #E(F_p^m) = 1 mod p literature = LiteratureReference( author=[ "Alfred Menezes", "Scott Vanstone", "Tatsuaki Okamoto" ], title= "Reducing Elliptic Curve Logarithms to Logarithms in a Finite Field", year=1991, source="ACM") judgements += SecurityJudgement( JudgementCode. X509Cert_PublicKey_ECC_DomainParameters_CurveProperty_SupersingularCurve, "This curve is supersingular, #E(F_p^m) = 1 mod p. The curve can be attacked using an probabilistic polynomial-time MOV attack.", bits=0, commonness=Commonness.HIGHLY_UNUSUAL, literature=literature) if not NumberTheory.is_probable_prime(curve.m): literature = LiteratureReference( author=[ "Jeffrey Hoffstein", "Jill Pipher", "Joseph Silverman" ], title="An Introduction to Mathematical Cryptography", year=2008, source="Springer") judgements += SecurityJudgement( JudgementCode. X509Cert_PublicKey_ECC_DomainParameters_CurveProperty_WeilDescent, "Binary finite field elliptic curve has a field size that is non-primem, F(2^%d). Weil Descent attacks could be successful.", bits=0, commonness=Commonness.HIGHLY_UNUSUAL, literature=literature) return judgements
def create(cls, p, q, e=0x10001, swap_e_d=False, valid_only=True, carmichael_totient=False): n = p * q if not carmichael_totient: totient = (p - 1) * (q - 1) else: totient = NumberTheory.lcm(p - 1, q - 1) gcd = NumberTheory.gcd(e, totient) if (gcd != 1) and valid_only: raise KeyCorruptException( "e = 0x%x isnt't relative prime to totient, gcd = 0x%x. Either accept broken keys or fix e." % (e, gcd)) d = NumberTheory.modinv(e, totient) if swap_e_d: (e, d) = (d, e) dmp1 = d % (p - 1) dmq1 = d % (q - 1) iqmp = NumberTheory.modinv(q, p) asn1 = cls._ASN1_MODEL() asn1["version"] = 0 asn1["modulus"] = n asn1["publicExponent"] = e asn1["privateExponent"] = d asn1["prime1"] = p asn1["prime2"] = q asn1["exponent1"] = dmp1 asn1["exponent2"] = dmq1 asn1["coefficient"] = iqmp der = pyasn1.codec.der.encoder.encode(asn1) return cls(der)
def test_gcd_n_phi_n(self): with tempfile.TemporaryDirectory() as tempdir, WorkDir(tempdir): PrimeDB().add(0x1fd22b50d1e28365855635, 0x3af25062dcf148b85084f5).write() output = self._run_x509sak( ["genbrokenrsa", "--bitlen", "257", "--gcd-n-phi-n", "-v"]).stdout key = RSAPrivateKey.read_pemfile("broken_rsa.key")[0] self.assertEqual(key.n.bit_length(), 257) self.assertEqual(key.p, 0x1fd22b50d1e28365855635) self.assertEqual(key.q, 0xea778f672d05715314fd556a2667dca7743e33da973) self.assertEqual((key.q - 1) % (2 * key.p), 0) self.assertNotEqual(NumberTheory.gcd(key.n, key.phi_n), 1) self.assertEqual(key.e, 0x10001) self.assertIn(b"gcd(n, phi(n)) = p", output) key.check_integrity()
def analyze(self, signature_alg_oid, signature_alg_params, signature, root_cert=None): judgements = SecurityJudgements() signature_alg = SignatureAlgorithms.lookup("oid", signature_alg_oid) if signature_alg is None: judgements += SecurityJudgement( JudgementCode.X509Cert_Signature_Function_Unknown, "Certificate has unknown signature algorithm with OID %s. Cannot make security determination." % (signature_alg_oid), commonness=Commonness.HIGHLY_UNUSUAL, compatibility=Compatibility.LIMITED_SUPPORT) result = { "name": str(signature_alg_oid), "pretty": str(signature_alg_oid), "security": judgements, } return result if isinstance(signature_alg.value.oid, (tuple, list)): # Have more than one OID for this if signature_alg.value.oid[0] != signature_alg_oid: judgements += SecurityJudgement( JudgementCode.X509Cert_Signature_Function_DeprecatedOID, "Signature algorithm uses alternate OID %s for algorithm %s. Preferred OID would be %s." % (signature_alg_oid, signature_alg.name, signature_alg.value.oid[0]), commonness=Commonness.HIGHLY_UNUSUAL, compatibility=Compatibility.LIMITED_SUPPORT) if signature_alg.value.hash_fnc is not None: # Signature algorithm already implies a concrete hash function, already done. hash_fnc = signature_alg.value.hash_fnc else: # Signature algorithms depends and is not implied. (hash_fnc, new_judgements) = self._determine_hash_function( signature_alg, signature_alg_params) judgements += new_judgements if signature_alg.value.sig_fnc == SignatureFunctions.ecdsa: # Decode ECDSA signature asn1_details = ASN1Tools.safe_decode( signature, asn1_spec=rfc3279.ECDSA_Sig_Value()) judgements += self._DER_VALIDATOR_ECDSA_SIGNATURE.validate( asn1_details) if (asn1_details.asn1 is not None) and (root_cert is not None): if root_cert.pubkey.pk_alg.value.cryptosystem == Cryptosystems.ECC_ECDSA: # Check that this is really a potential parent CA certificate ca_curve = root_cert.pubkey.curve hweight_analysis = NumberTheory.hamming_weight_analysis( int(asn1_details.asn1["r"]), min_bit_length=ca_curve.field_bits) if not hweight_analysis.plausibly_random: judgements += SecurityJudgement( JudgementCode. X509Cert_Signature_ECDSA_R_BitBiasPresent, "Hamming weight of ECDSA signature R parameter is %d at bitlength %d, but expected a weight between %d and %d when randomly chosen; this is likely not coincidential." % (hweight_analysis.hweight, hweight_analysis.bitlen, hweight_analysis.rnd_min_hweight, hweight_analysis.rnd_max_hweight), commonness=Commonness.HIGHLY_UNUSUAL) hweight_analysis = NumberTheory.hamming_weight_analysis( int(asn1_details.asn1["s"]), min_bit_length=ca_curve.field_bits) if not hweight_analysis.plausibly_random: judgements += SecurityJudgement( JudgementCode. X509Cert_Signature_ECDSA_S_BitBiasPresent, "Hamming weight of ECDSA signature S parameter is %d at bitlength %d, but expected a weight between %d and %d when randomly chosen; this is likely not coincidential." % (hweight_analysis.hweight, hweight_analysis.bitlen, hweight_analysis.rnd_min_hweight, hweight_analysis.rnd_max_hweight), commonness=Commonness.HIGHLY_UNUSUAL) elif signature_alg.value.sig_fnc == SignatureFunctions.dsa: # Decode DSA signature asn1_details = ASN1Tools.safe_decode( signature, asn1_spec=rfc3279.Dss_Sig_Value()) judgements += self._DER_VALIDATOR_DSA_SIGNATURE.validate( asn1_details) if (asn1_details.asn1 is not None) and (root_cert is not None): if root_cert is not None: if root_cert.pubkey.pk_alg.value.cryptosystem == Cryptosystems.DSA: field_width = root_cert.pubkey.q.bit_length() hweight_analysis = NumberTheory.hamming_weight_analysis( int(asn1_details.asn1["r"]), min_bit_length=field_width) if not hweight_analysis.plausibly_random: judgements += SecurityJudgement( JudgementCode. X509Cert_Signature_DSA_R_BitBiasPresent, "Hamming weight of DSA signature R parameter is %d at bitlength %d, but expected a weight between %d and %d when randomly chosen; this is likely not coincidential." % (hweight_analysis.hweight, hweight_analysis.bitlen, hweight_analysis.rnd_min_hweight, hweight_analysis.rnd_max_hweight), commonness=Commonness.HIGHLY_UNUSUAL) hweight_analysis = NumberTheory.hamming_weight_analysis( int(asn1_details.asn1["s"]), min_bit_length=field_width) if not hweight_analysis.plausibly_random: judgements += SecurityJudgement( JudgementCode. X509Cert_Signature_DSA_S_BitBiasPresent, "Hamming weight of DSA signature S parameter is %d at bitlength %d, but expected a weight between %d and %d when randomly chosen; this is likely not coincidential." % (hweight_analysis.hweight, hweight_analysis.bitlen, hweight_analysis.rnd_min_hweight, hweight_analysis.rnd_max_hweight), commonness=Commonness.HIGHLY_UNUSUAL) result = { "name": signature_alg.name, "sig_fnc": self.algorithm("sig_fnc").analyze(signature_alg.value.sig_fnc), "security": judgements, } if hash_fnc is not None: result.update({ "pretty": signature_alg.value.sig_fnc.value.pretty_name + " with " + hash_fnc.value.pretty_name, "hash_fnc": self.algorithm("hash_fnc").analyze(hash_fnc), }) else: result.update({ "pretty": "%s with undetermined hash function" % (signature_alg.value.sig_fnc.value.pretty_name) }) return result
def test_pollard_rho_give_up(self): # There's a small chance this test may fail. :-) p = 95749913370328252241446208736506188314802113993127662348226135040475696867509 q = 99274458486037483860318981815348157562287354834427068777154647405331367500099 n = p * q self.assertIsNone(NumberTheory.pollard_rho(n, max_iterations=10))
def test_pollard_rho_success(self): p = 3513360553 q = 2841366767 n = p * q self.assertIn(NumberTheory.pollard_rho(n), [p, q])
def test_pollard_rho_prime(self): p = 2003 self.assertIsNone(NumberTheory.pollard_rho(p), None)
def test_crt(self): moduli = {2: 1, 3: 2, 5: 3, 7: 4, 11: 5, 13: 6, 17: 7} solution = NumberTheory.solve_crt(moduli) for (modulus, remainder) in moduli.items(): self.assertEqual(solution % modulus, remainder)
def test_modinv(self): p = 2003 for _ in range(10): r = random.randint(2, p - 1) inv = NumberTheory.modinv(r, p) self.assertEqual(r * inv % p, 1)
def test_isqrt(self): self.assertEqual(NumberTheory.isqrt(0), 0) self.assertEqual(NumberTheory.isqrt(1), 1) self.assertEqual(NumberTheory.isqrt(2), 1) self.assertEqual(NumberTheory.isqrt(3), 1) self.assertEqual(NumberTheory.isqrt(4), 2) self.assertEqual(NumberTheory.isqrt(5), 2) self.assertEqual(NumberTheory.isqrt(27), 5) self.assertEqual(NumberTheory.isqrt(35), 5) self.assertEqual(NumberTheory.isqrt(36), 6) self.assertEqual(NumberTheory.isqrt(123456789), 11111) self.assertEqual(NumberTheory.isqrt(1234567890), 35136)
def test_factor(self): self.assertEqual(list(sorted(NumberTheory.factor(101 * 211))), [101, 211]) self.assertEqual(list(sorted(NumberTheory.factor(2**4))), [2, 2, 2, 2]) self.assertEqual(list(sorted(NumberTheory.factor(2 * 2 * 3 * 3 * 5))), [2, 2, 3, 3, 5])
def test_find_small_factor(self): self.assertEqual(NumberTheory.find_small_factor(7 * 2003), 7) self.assertEqual(NumberTheory.find_small_factor(2 * 2003), 2) self.assertEqual(NumberTheory.find_small_factor(71 * 2003), 71) self.assertEqual(NumberTheory.find_small_factor(2003), None)