Skip to content

Commit 7888d7f

Browse files
committed
test(rng): prove entropy audit budget policy
1 parent 9c58e67 commit 7888d7f

3 files changed

Lines changed: 64 additions & 21 deletions

File tree

‎scripts/generate-test-report.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -644,8 +644,12 @@ def _arg_shown(a):
644644
'or information leaks. Verifies input sanitization.',
645645
[]),
646646
('C27', 'test_msg_getentropy', 'test_entropy',
647-
'Hardware RNG entropy',
648-
'Reads random bytes from the hardware RNG. Used to verify the entropy source is functional.',
647+
'Hardware RNG audit budget and lock policy',
648+
'Proves a fresh initialized, PIN-protected, locked device still requires confirmation; '
649+
'then proves an uninitialized device returns exactly 8 x 8192 bytes (64 KiB) without a '
650+
'press, with exact lengths, unique blocks, and conservative catastrophic-failure health '
651+
'checks. The next request must restore confirmation. These checks detect a stuck or '
652+
'grossly biased source; they are not a statistical certification of the hardware RNG.',
649653
[]),
650654
('C28', 'test_msg_cipherkeyvalue', 'test_encrypt',
651655
'Symmetric key encryption',

‎tests/test_msg_getentropy.py‎

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,35 +20,73 @@
2020

2121
from __future__ import print_function
2222

23+
import os
2324
import unittest
2425
import common
25-
import math
26+
from collections import Counter
2627

2728
import keepkeylib.messages_pb2 as proto
2829
import keepkeylib.types_pb2 as proto_types
2930

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-
4331
class TestMsgGetentropy(common.KeepKeyTest):
4432

33+
@unittest.skipUnless(
34+
os.getenv('KK_EXPECT_ENTROPY_BUDGET') == '1',
35+
'requires the RC23 entropy audit budget policy')
4536
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):
4759
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)
5290

5391
if __name__ == '__main__':
5492
unittest.main()

‎tests/test_protection_levels.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def test_ping(self):
6868
def test_get_entropy(self):
6969
with self.client:
7070
self.setup_mnemonic_pin_passphrase()
71+
self.client.clear_session()
7172
self.client.set_expected_responses([proto.ButtonRequest(),
7273
proto.Entropy()])
7374
self.client.get_entropy(10)

0 commit comments

Comments
 (0)