From 57d8ee72f5f7dfa36eeda1ab70594b0b5f971ade Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 27 Sep 2026 02:24:32 -0500 Subject: [PATCH 1/2] test(authenticator): cover identity bounds and consent on both variants --- scripts/generate-test-report.py | 22 +++++ tests/test_msg_authenticator_boundaries.py | 101 +++++++++++++++++++++ tests/test_report_variant_validation.py | 23 +++++ 3 files changed, 146 insertions(+) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 290947da..cc79ea65 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -536,6 +536,27 @@ def _arg_shown(a): _V_CATALOG_TESTS = _v_catalog_tests(start_id=17) SECTIONS = [ + ('AU', 'Authenticator identity and consent', '7.15.0', + 'New credentials use bounded printable identities and 16-20 byte secrets. ' + 'Both confirmations are required; duplicates never replace an existing secret.', + ['Applies to full and Bitcoin-only builds. Legacy short secrets remain usable.'], + [('AU1', 'test_msg_authenticator_boundaries', + 'test_block09_add_reviews_complete_identity_and_secret', + 'Complete identity and secret review', + 'Maximum-length identities and the entire secret appear on separate confirmations.', + ['Identity', 'Secret']), + ('AU2', 'test_msg_authenticator_boundaries', + 'test_block09_duplicate_and_cancellation_have_exact_failures', + 'Refusal, retry and duplicate handling', + 'Declining either screen stores nothing; immediate retry succeeds and duplicates fail before review.', []), + ('AU3', 'test_msg_authenticator_boundaries', + 'test_block09_invalid_identity_and_secret_fail_before_buttons', + 'Malformed and aliased identities are refused', + 'Empty, overlong, non-ASCII and control-containing identities and invalid secrets fail before consent. OTP prefix aliases fail.', []), + ('AU4', 'test_msg_authenticator_boundaries', + 'test_block09_delete_decline_preserves_account_then_retry_removes', + 'Deletion requires consent', + 'Declining deletion preserves the account; an immediate confirmed retry removes it.', [])]), ('J', 'Display Binding - What the Device Signs Is What It Shows', '7.14.2', 'The 7.14.2 security release changed what reaches the OLED on the signing paths. Every ' 'defect it fixed was a case of the device hashing bytes it never rendered, or rendering ' @@ -3884,6 +3905,7 @@ def screenshot_test_list(fw_version): # version-blind set would fail every older-firmware run for a module that # legitimately cannot exist yet. MUST_RUN_MODULES = { + 'test_msg_authenticator_boundaries': '7.15.0', # Taproot did not exist at 7.0.0. That floor only ever held because this # table was applied to products that happen to carry taproot: 7.14.2 # reports no supports_taproot and has no P2TR path in signing.c at all, so diff --git a/tests/test_msg_authenticator_boundaries.py b/tests/test_msg_authenticator_boundaries.py index 8f0e8417..dca35882 100644 --- a/tests/test_msg_authenticator_boundaries.py +++ b/tests/test_msg_authenticator_boundaries.py @@ -71,6 +71,107 @@ def test_authorization_loss_drops_cache_and_requires_reauthorization(self): self.assertIsInstance(response, proto.Success, name) self.assertEqual(response.message, 'example:alice') + def _reset_accounts(self): + self.setup_mnemonic_nopin_nopassphrase() + self.assertIsInstance(self._auth_ping(self.WIPE_ACCOUNTS), proto.Success) + common.reset_screenshot_capture(self.client) + + def _walk_auth(self, message, reject=None): + response = self.client.call_raw(proto.Ping(message=message)) + screens = [] + for _ in range(12): + if not isinstance(response, proto.ButtonRequest): + return response, screens + screens.append(self.client.debug.read_confirm_text()) + self.client.capture_oled() + if len(screens) == reject: + self.client.debug.press_no() + else: + self.client.debug.press_yes() + response = self.client.call_raw(proto.ButtonAck()) + self.fail('authenticator did not terminate within 12 screens') + + def test_block09_add_reviews_complete_identity_and_secret(self): + self._reset_accounts() + secret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' + response, screens = self._walk_auth( + '\x15initializeAuth:abcdefghijk:ABCDEFGHIJK:' + secret) + self.assertIsInstance(response, proto.Success) + self.assertFalse(response.HasField('message')) + self.assertEqual(screens, [ + ('Add Auth Account', 'Domain: abcdefghijk\nAccount: ABCDEFGHIJK'), + ('TOTP Secret', secret)]) + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, + 'abcdefghijk:ABCDEFGHIJK') + + def test_block09_duplicate_and_cancellation_have_exact_failures(self): + self._reset_accounts() + for reject in (1, 2): + response, screens = self._walk_auth(self.ADD_ACCOUNT, reject=reject) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.code, proto_types.Failure_ActionCancelled) + self.assertEqual(response.message, 'Action cancelled') + self.assertEqual(len(screens), reject) + missing = self.client.call_raw(proto.Ping(message=self.GET_ACCOUNT)) + self.assertIsInstance(missing, proto.Failure) + self.assertEqual(missing.message, 'Account not found') + response, screens = self._walk_auth(self.ADD_ACCOUNT) + self.assertIsInstance(response, proto.Success) + self.assertEqual(len(screens), 2) + # No Initialize or reload between refusal and retry or duplicate. + duplicate = self.client.call_raw(proto.Ping(message=self.ADD_ACCOUNT)) + self.assertIsInstance(duplicate, proto.Failure) + self.assertEqual(duplicate.message, 'Authenticator account already exists') + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, 'example:alice') + + def test_block09_invalid_identity_and_secret_fail_before_buttons(self): + self._reset_accounts() + secret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' + credentials = [ + ':alice:' + secret, 'example::' + secret, + '::example:alice:' + secret, 'exam\nple:alice:' + secret, + 'example:ali\tce:' + secret, 'example:\u00e9:' + secret, + 'abcdefghijkl:alice:' + secret, 'example:abcdefghijkl:' + secret, + 'example:alice:' + 'A' * 25, 'example:alice:' + 'A' * 34, + 'example:alice:' + '!' * 32, + ] + for credential in credentials: + with self.subTest(credential=credential): + response = self.client.call_raw(proto.Ping( + message='\x15initializeAuth:' + credential)) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.code, proto_types.Failure_ActionCancelled) + response, _ = self._walk_auth( + '\x15initializeAuth:abcdefghijk:ABCDEFGHIJK:' + 'A' * 26) + self.assertIsInstance(response, proto.Success) + for request in ( + '\x16generateOTPFrom:abcdefghijkX:ABCDEFGHIJK:1:30', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJKX:1:30', + '\x16generateOTPFrom::abcdefghijk:ABCDEFGHIJK:1:30', + '\x18removeAccount::abcdefghijk:ABCDEFGHIJK', + '\x18removeAccount:abcdefghijk:ABCDEFGHIJKX'): + response = self.client.call_raw(proto.Ping(message=request)) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, + 'abcdefghijk:ABCDEFGHIJK') + + def test_block09_delete_decline_preserves_account_then_retry_removes(self): + self._reset_accounts() + response, _ = self._walk_auth(self.ADD_ACCOUNT) + self.assertIsInstance(response, proto.Success) + request = '\x18removeAccount:example:alice' + response, screens = self._walk_auth(request, reject=1) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.message, 'Action cancelled') + self.assertEqual(len(screens), 1) + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, 'example:alice') + response, screens = self._walk_auth(request) + self.assertIsInstance(response, proto.Success) + self.assertEqual(len(screens), 1) + missing = self.client.call_raw(proto.Ping(message=self.GET_ACCOUNT)) + self.assertIsInstance(missing, proto.Failure) + self.assertEqual(missing.message, 'Account not found') + if __name__ == '__main__': unittest.main() diff --git a/tests/test_report_variant_validation.py b/tests/test_report_variant_validation.py index 5e26709d..fb4ed071 100644 --- a/tests/test_report_variant_validation.py +++ b/tests/test_report_variant_validation.py @@ -128,6 +128,29 @@ def test_stack08_runtime_controls_are_required_on_full_only(self): self.assertEqual((True, []), REPORT.validate_junit( '7.15.0', changed, 'bitcoin-only')) + def test_authenticator_contracts_cannot_skip_on_either_variant(self): + results = catalog_results_with_solana_lut_skipped('7.15.0') + for key in results: + results[key] = 'pass' + owned = [key for key in results if key.startswith( + 'test_msg_authenticator_boundaries::test_block09_')] + self.assertEqual(4, len(owned)) + for variant in ('full', 'bitcoin-only'): + self.assertEqual((True, []), REPORT.validate_junit( + '7.15.0', results, variant)) + for key in owned: + for status in ('skip', 'fail', 'missing'): + with self.subTest(variant=variant, key=key, status=status): + changed = dict(results) + if status == 'missing': + del changed[key] + else: + changed[key] = status + ok, failures = REPORT.validate_junit( + '7.15.0', changed, variant) + self.assertFalse(ok) + self.assertEqual(1, len(failures)) + class TestReportVariantEnvironmentIsolation(unittest.TestCase): From 41af909341965c81b8afadd38f299d6f929089af Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 27 Sep 2026 02:30:38 -0500 Subject: [PATCH 2/2] test(authenticator): reject malformed OTP timing at the wire boundary --- tests/test_msg_authenticator_boundaries.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_msg_authenticator_boundaries.py b/tests/test_msg_authenticator_boundaries.py index dca35882..49e90dd1 100644 --- a/tests/test_msg_authenticator_boundaries.py +++ b/tests/test_msg_authenticator_boundaries.py @@ -149,7 +149,11 @@ def test_block09_invalid_identity_and_secret_fail_before_buttons(self): '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJKX:1:30', '\x16generateOTPFrom::abcdefghijk:ABCDEFGHIJK:1:30', '\x18removeAccount::abcdefghijk:ABCDEFGHIJK', - '\x18removeAccount:abcdefghijk:ABCDEFGHIJKX'): + '\x18removeAccount:abcdefghijk:ABCDEFGHIJKX', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:+1:30', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:1:31', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:1:30junk', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:4294967296:30'): response = self.client.call_raw(proto.Ping(message=request)) self.assertIsInstance(response, proto.Failure) self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message,