|
20 | 20 |
|
21 | 21 | from __future__ import print_function |
22 | 22 |
|
| 23 | +import os |
23 | 24 | import unittest |
24 | 25 | import common |
25 | | -import math |
| 26 | +from collections import Counter |
26 | 27 |
|
27 | 28 | import keepkeylib.messages_pb2 as proto |
28 | 29 | import keepkeylib.types_pb2 as proto_types |
29 | 30 |
|
30 | | -def entropy(data): |
31 | | - counts = {} |
32 | | - for c in data: |
33 | | - if c in counts: |
34 | | - counts[c] += 1 |
35 | | - else: |
36 | | - counts[c] = 1 |
37 | | - e = 0 |
38 | | - for _, v in counts.items(): |
39 | | - p = 1.0 * v / len(data) |
40 | | - e -= p * math.log(p, 256) |
41 | | - return e |
42 | | - |
43 | 31 | class TestMsgGetentropy(common.KeepKeyTest): |
44 | 32 |
|
| 33 | + @unittest.skipUnless( |
| 34 | + os.getenv('KK_EXPECT_ENTROPY_BUDGET') == '1', |
| 35 | + 'requires the RC23 entropy audit budget policy') |
45 | 36 | def test_entropy(self): |
46 | | - for l in [0, 1, 2, 3, 4, 5, 8, 9, 16, 17, 32, 33, 64, 65, 128, 129, 256, 257, 512, 513, 1024]: |
| 37 | + chunk_size = 8192 |
| 38 | + chunk_count = 8 |
| 39 | + |
| 40 | + # A fresh budget must not make raw RNG output silently available from |
| 41 | + # an initialized, PIN-protected, locked device. Confirm one request in |
| 42 | + # that state before spending any of the press-free budget. |
| 43 | + self.setup_mnemonic_pin_passphrase() |
| 44 | + self.client.clear_session() |
| 45 | + with self.client: |
| 46 | + self.client.set_expected_responses([ |
| 47 | + proto.ButtonRequest(code=proto_types.ButtonRequest_GetEntropy), |
| 48 | + proto.Entropy(), |
| 49 | + ]) |
| 50 | + locked_sample = self.client.get_entropy(chunk_size) |
| 51 | + self.assertEqual(len(locked_sample), chunk_size) |
| 52 | + |
| 53 | + # Wiping returns the device to the uninitialized audit state. The |
| 54 | + # confirmed locked request above does not consume the fresh budget. |
| 55 | + self.client.wipe_device() |
| 56 | + |
| 57 | + samples = [] |
| 58 | + for _ in range(chunk_count): |
47 | 59 | with self.client: |
48 | | - self.client.set_expected_responses([proto.ButtonRequest(code=proto_types.ButtonRequest_GetEntropy), proto.Entropy()]) |
49 | | - ent = self.client.get_entropy(l) |
50 | | - self.assertTrue(len(ent) >= l) |
51 | | - print('entropy = ', entropy(ent)) |
| 60 | + self.client.set_expected_responses([proto.Entropy()]) |
| 61 | + sample = self.client.get_entropy(chunk_size) |
| 62 | + self.assertEqual(len(sample), chunk_size) |
| 63 | + samples.append(sample) |
| 64 | + |
| 65 | + self.assertEqual(sum(len(sample) for sample in samples), 64 * 1024) |
| 66 | + self.assertEqual(len(set(samples)), chunk_count) |
| 67 | + |
| 68 | + # Deliberately broad catastrophic-failure checks, not a statistical |
| 69 | + # certification of the hardware RNG. They catch a stuck/constant or |
| 70 | + # grossly biased source without imposing a fragile quality threshold. |
| 71 | + combined = b''.join(samples) |
| 72 | + counts = Counter(combined) |
| 73 | + self.assertGreaterEqual(len(counts), 200) |
| 74 | + self.assertLess(max(counts.values()), len(combined) // 20) |
| 75 | + one_bits = sum(bin(value).count('1') for value in combined) |
| 76 | + one_ratio = float(one_bits) / (8 * len(combined)) |
| 77 | + self.assertGreater(one_ratio, 0.40) |
| 78 | + self.assertLess(one_ratio, 0.60) |
| 79 | + |
| 80 | + # Exactly 64 KiB was press-free. The next request must restore the |
| 81 | + # original confirmation flow and still return the requested length |
| 82 | + # after the debug-link approval. |
| 83 | + with self.client: |
| 84 | + self.client.set_expected_responses([ |
| 85 | + proto.ButtonRequest(code=proto_types.ButtonRequest_GetEntropy), |
| 86 | + proto.Entropy(), |
| 87 | + ]) |
| 88 | + after_budget = self.client.get_entropy(chunk_size) |
| 89 | + self.assertEqual(len(after_budget), chunk_size) |
52 | 90 |
|
53 | 91 | if __name__ == '__main__': |
54 | 92 | unittest.main() |
0 commit comments