Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions scripts/generate-test-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions tests/test_msg_authenticator_boundaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,111 @@ 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',
'\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,
'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()
23 changes: 23 additions & 0 deletions tests/test_report_variant_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down