test(core): test mock RNGs for Optiga and Tropic
What changed, and why it matters
This commit only adds new automated tests and a helper script for generating test data. It does not change the firmware's random number generator, wallet operations, or any user-facing behavior. The goal is to catch future bugs where a hardware model's entropy source might accidentally be left out of the strong randomness mix.
No security action required; treat as a normal test-only commit. Reviewers may optionally confirm the mock-vector generator matches the emulator's rng_mock.c implementation.
Security signals we found
Adds regression tests for strong RNG composition across models
References secure-element entropy sources (Optiga, Tropic) in test vectors
Verifies exact deterministic output of rng_fill_buffer_strong() on emulator
No patch to RNG implementation or cryptographic code
Evidence from the diff
The commit introduces deterministic mock-vector tests for the strong RNG path (rng_fill_buffer_strong()) across Trezor Core models. It adds a unit test, two device tests, and tools/gen_rng_mock_vectors.py, which computes expected output by XORing a mocked MCU PRNG stream with mocked Optiga/Tropic secure-element streams per model. The tests assert exact byte outputs after reseeding, intended to detect regressions such as a dropped entropy source, missing strong=True flag, or truncated buffer. No production code is modified.
Changed components
core/tests/test_trezor.crypto.random_strong.pytests/device_tests/misc/test_msg_getentropy.pytests/device_tests/reset_recovery/test_reset_bip39_t2.pytools/gen_rng_mock_vectors.pytests/ui_tests/fixtures.jsonInspect captured patch +969 / −674
### core/tests/test_trezor.crypto.random_strong.py
@@ -0,0 +1,47 @@
+# flake8: noqa: F403,F405
+from common import * # isort:skip
+
+from trezor.crypto import random
+
+# On the emulator every entropy source is deterministic and unique.
+#
+# Asserting exact strong-RNG output verifies, end to end through the
+# upymod binding and rng_fill_buffer_strong(), that every entropy source of
+# the model contributed at every byte position. A dropped source, a lost
+# strong=True, a truncated buffer each produce a different, wrong answer.
+
+# generated with tools/gen_rng_mock_vectors.py --plain / --model MODEL
+_PLAIN = "ccc07780286893c51eef095d7b83e887171d1fafdd2b26477d1db3306a583c73"
+_STRONG = {
+ "T2T1": "ccc07780286893c51eef095d7b83e887171d1fafdd2b26477d1db3306a583c73",
+ "T2B1": "3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3",
+ "T3B1": "3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3",
+ "T3T1": "3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3",
+ "T3W1": "af5f5d845074ad2c85a11a28aef337677d022951da5856e7231ab050d420db18",
+}
+
+
+class TestCryptoRandomStrong(unittest.TestCase):
+ def test_weak(self):
+ random.reseed(0)
+ self.assertEqual(random.bytes(32).hex(), _PLAIN)
+
+ def test_strong(self):
+ expected = _STRONG[utils.INTERNAL_MODEL]
+
+ random.reseed(0)
+ self.assertEqual(random.bytes(32, True).hex(), expected)
+
+ def test_reseed_isolation(self):
+ random.reseed(0)
+ a = random.bytes(32, True)
+ random.reseed(1)
+ b = random.bytes(32, True)
+ random.reseed(0)
+ c = random.bytes(32, True)
+ self.assertNotEqual(a, b)
+ self.assertEqual(a, c)
+
+
+if __name__ == "__main__":
+ unittest.main()
### tests/device_tests/misc/test_msg_getentropy.py
@@ -48,3 +48,81 @@ def test_entropy(session: Session, entropy_length):
ent = misc.get_entropy(session, entropy_length)
assert len(ent) == entropy_length
print(f"{entropy_length} bytes: entropy = {entropy(ent)}")
+
+
+# Expected GetEntropy output on the emulator after random.reseed(0).
+# Generated by tools/gen_rng_mock_vectors.py --model MODEL --length LENGTH --mcu-offset 0
+# If the vectors fail unexpectedly, some unrelated firmware code probably
+# started drawing plain RNG between the reseed and GetEntropy, shifting the
+# stream. In that case regenerate with the matching --mcu-offset.
+#
+MOCK_ENTROPY_VECTORS = {
+ ("T2T1", 32): ("ccc07780286893c51eef095d7b83e887171d1fafdd2b26477d1db3306a583c73"),
+ ("T2T1", 40): (
+ "ccc07780286893c51eef095d7b83e887171d1fafdd2b26477d1db3306a583c73cf7d1a7e63983ae1"
+ ),
+ ("T2B1", 32): ("3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3"),
+ ("T2B1", 40): (
+ "3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3b54b81a555ea85b6"
+ ),
+ ("T3B1", 32): ("3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3"),
+ ("T3B1", 40): (
+ "3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3b54b81a555ea85b6"
+ ),
+ ("T3T1", 32): ("3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3"),
+ ("T3T1", 40): (
+ "3bb68a45c8e628aaf3d1c251f406513c5ca95c6c61a73ac8454167d9a0c37bf3b54b81a555ea85b6"
+ ),
+ ("T3W1", 32): ("af5f5d845074ad2c85a11a28aef337677d022951da5856e7231ab050d420db18"),
+ ("T3W1", 40): (
+ "af5f5d845074ad2c85a11a28aef337677d022951da5856e7231ab050d420db1839b2e376e2c1c715"
+ ),
+}
+
+
+@pytest.mark.models("core")
+@pytest.mark.parametrize("entropy_length", [32, 40])
+def test_entropy_hardware_rng(session: Session, entropy_length):
+ if session.test_ctx.is_emulator:
+ pytest.skip("Only for hardware")
+
+ model = session.features.internal_model
+ mock_entropy = MOCK_ENTROPY_VECTORS.get((model, entropy_length))
+ assert mock_entropy is not None, f"no test vector for {model}/{entropy_length}"
+
+ with session.test_ctx as client:
+ client.set_expected_responses(
+ [m.ButtonRequest(code=m.ButtonRequestType.ProtectCall), m.Entropy]
+ )
+ ent = misc.get_entropy(session, entropy_length)
+
+ assert len(ent) == entropy_length
+ assert ent.hex() != mock_entropy
+
+ with session.test_ctx as client:
+ client.set_expected_responses(
+ [m.ButtonRequest(code=m.ButtonRequestType.ProtectCall), m.Entropy]
+ )
+ ent2 = misc.get_entropy(session, entropy_length)
+
+ assert len(ent2) == entropy_length
+ assert ent2 != ent
+
+
+@pytest.mark.models("core")
+@pytest.mark.emulator
+@pytest.mark.parametrize("entropy_length", [32, 40])
+def test_entropy_mock_streams(session: Session, entropy_length):
+ model = session.features.internal_model
+ expected = MOCK_ENTROPY_VECTORS.get((model, entropy_length))
+ assert expected is not None, f"no test vector for {model}/{entropy_length}"
+
+ session.debug.reseed(0)
+ with session.test_ctx as client:
+ client.set_expected_responses(
+ [m.ButtonRequest(code=m.ButtonRequestType.ProtectCall), m.Entropy]
+ )
+ ent = misc.get_entropy(session, entropy_length)
+
+ assert len(ent) == entropy_length
+ assert ent.hex() == expected
### tests/device_tests/reset_recovery/test_reset_bip39_t2.py
@@ -35,6 +35,22 @@
pytestmark = pytest.mark.models("core")
+# Internal entropy of the final ResetDevice round, per model, after
+# random.reseed(MOCK_SEED).
+# Generated with:
+# tools/gen_rng_mock_vectors.py --model MODEL --seed 3735928559 --mcu-offset 6
+# Unrelated firmware code consumes 48 words of plain RNG between the reseed and
+# the final round. If the vectors fail unexpectedly, that consumption probably
+# changed, shifting the stream; regenerate with the matching --mcu-offset.
+MOCK_SEED = 0xDEADBEEF
+MOCK_INTERNAL_ENTROPY = {
+ "T2T1": "3f23f500a7890ccce4d155d762b32f1945cdf3341917f444c0fbbe553a6bb618",
+ "T2B1": "ab347f0f69415b32f942f5bbe3884800e87025a70ab99c7fc910d91b20524b5c",
+ "T3B1": "ab347f0f69415b32f942f5bbe3884800e87025a70ab99c7fc910d91b20524b5c",
+ "T3T1": "ab347f0f69415b32f942f5bbe3884800e87025a70ab99c7fc910d91b20524b5c",
+ "T3W1": "c548d5685b1996e777094285b4ae74a50fc3a83be0a5424e483e13c65f626c2a",
+}
+
FLOW_ADAPTERS = [
normal,
try_to_cancel(
@@ -188,6 +204,53 @@ def test_reset_entropy_check(test_ctx: TrezorTestContext):
assert res.xpub == xpub
+@pytest.mark.emulator
+@pytest.mark.setup_client(uninitialized=True)
+def test_reset_entropy_mock_streams(session: Session):
+ """Verify that the seed's internal entropy is exactly what the model's
+ deterministic emulator entropy sources produce together, proving on the
+ running firmware that every source contributed at every byte position. A
+ dropped source, a lost strong=True, a truncated buffer each give a
+ different value."""
+ STRENGTH = 128
+ MOCK_ENTROPY_CHECK_COUNT = 1
+ debug = session.debug
+ model = session.features.internal_model
+ calls = 0
+
+ def get_entropy() -> bytes:
+ nonlocal calls
+ calls += 1
+ if calls == MOCK_ENTROPY_CHECK_COUNT:
+ debug.reseed(MOCK_SEED)
+ return EXTERNAL_ENTROPY
+
+ with session.test_ctx as client:
+ IF = InputFlowBip39ResetBackup(session)
+ client.set_input_flow(IF.get())
+ device.setup(
+ session,
+ strength=STRENGTH,
+ passphrase_protection=False,
+ pin_protection=False,
+ label="test",
+ entropy_check_count=MOCK_ENTROPY_CHECK_COUNT,
+ backup_type=messages.BackupType.Bip39,
+ _get_entropy=get_entropy,
+ )
+
+ assert calls == MOCK_ENTROPY_CHECK_COUNT + 1
+
+ internal_entropy = debug.state().reset_entropy
+ assert internal_entropy is not None
+
+ expected = MOCK_INTERNAL_ENTROPY.get(model)
+ assert internal_entropy.hex() == expected
+
+ entropy = generate_entropy(STRENGTH, internal_entropy, EXTERNAL_ENTROPY)
+ assert IF.mnemonic == Mnemonic("english").to_mnemonic(entropy)
+
+
@pytest.mark.setup_client(uninitialized=True)
def test_reset_failed_check(test_ctx: TrezorTestContext):
debug = test_ctx.debug
### tests/ui_tests/fixtures.json
[binary or diff unavailable]
### tools/gen_rng_mock_vectors.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""Generate expected strong-RNG output for the emulator's mock entropy streams."""
+
+from __future__ import annotations
+
+import hashlib
+
+import click
+
+MCU_TAG = "<PRNG-MCU>"
+OPTIGA_TAG = "<PRNG-Optiga>"
+TROPIC_TAG = "<PRNG-Tropic>"
+
+MODEL_SE_TAGS = {
+ "T2T1": (),
+ "T2B1": (OPTIGA_TAG,),
+ "T3B1": (OPTIGA_TAG,),
+ "T3T1": (OPTIGA_TAG,),
+ "T3W1": (OPTIGA_TAG, TROPIC_TAG),
+}
+
+
+def prng_stream(tag: str, length: int, seed: int = 0, offset: int = 0) -> bytes:
+ """An emulated secure element's stream (core/embed/sec/rng/unix/rng_mock.c)."""
+ out = b""
+ counter = offset
+ while len(out) < length:
+ msg = tag.encode() + seed.to_bytes(4, "little") + counter.to_bytes(4, "little")
+ digest = hashlib.sha256(msg).digest()
+ out += digest[: length - len(out)]
+ counter += 1
+ return out
+
+
+def strong(model: str, length: int, seed: int = 0, mcu_offset: int = 0) -> bytes:
+ """Expected rng_fill_buffer_strong() output of `length` bytes."""
+ out = bytearray(prng_stream(MCU_TAG, length, seed, mcu_offset))
+ for tag in MODEL_SE_TAGS[model]:
+ stream = prng_stream(tag, length, seed)
+ for i in range(length):
+ out[i] ^= stream[i]
+ return bytes(out)
+
+
+def plain(length: int, seed: int = 0, mcu_offset: int = 0) -> bytes:
+ """Expected random.bytes() output -- MCU stream only, no secure elements."""
+ return prng_stream(MCU_TAG, length, seed, mcu_offset)
+
+
+@click.command()
+@click.option(
+ "-m",
+ "--model",
+ type=click.Choice(sorted(MODEL_SE_TAGS)),
+ help="Internal model name, omit for --plain.",
+)
+@click.option("-l", "--length", type=int, default=32, help="Bytes to draw.")
+@click.option("-s", "--seed", type=int, default=0, help="random.reseed() value.")
+@click.option(
+ "-o",
+ "--mcu-offset",
+ type=int,
+ default=0,
+ metavar="N",
+ help="Skip N 32-byte PRNG blocks of MCU stream, for draws that follow other "
+ "plain-RNG consumption after the reseed.",
+)
+@click.option(
+ "-p",
+ "--plain",
+ "plain_only",
+ is_flag=True,
+ help="random.bytes() instead of random.bytes(n, True): MCU stream only.",
+)
+def main(
+ model: str | None, length: int, seed: int, mcu_offset: int, plain_only: bool
+) -> None:
+ if plain_only:
+ click.echo(plain(length, seed, mcu_offset).hex())
+ elif model:
+ click.echo(strong(model, length, seed, mcu_offset).hex())
+ else:
+ raise click.UsageError("need --model or --plain")
+
+
+if __name__ == "__main__":
+ main()Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.