1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-12-29 09:38:08 +00:00
trezor-firmware/tests/device_tests/test_session_id_and_passphrase.py
2024-11-12 16:55:17 +01:00

519 lines
21 KiB
Python

# This file is part of the Trezor project.
#
# Copyright (C) 2012-2019 SatoshiLabs and contributors
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3
# as published by the Free Software Foundation.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the License along with this library.
# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
import random
import pytest
from trezorlib import device, exceptions, messages
from trezorlib.debuglink import LayoutType
from trezorlib.debuglink import TrezorClientDebugLink as Client
from trezorlib.exceptions import TrezorFailure
from trezorlib.messages import FailureType, SafetyCheckLevel
from trezorlib.tools import parse_path
from .. import translations as TR
XPUB_PASSPHRASES = {
"A": "xpub6CekxGcnqnJ6osfY4Rrq7W5ogFtR54KUvz4H16XzaQuukMFZCGebEpVznfq4yFcKEmYyShwj2UKjL7CazuNSuhdkofF4mHabHkLxCMVvsqG",
"B": "xpub6CFxuyQpgryoR64QC38w42dLgDv5P4qWXhn1fbaN62UYzu1wJXZyrYqGnkq5d8xPUK68RXtXFBiqp3rfLGpeQ57zLtx675ZZn5ezKMAWQfu",
"C": "xpub6BhJMNFwCjGKyRb9RUcnuHhJ2TgcnurfUrQszrmZ1rg8aadsMXLySF6LY3qf4pR7bY4vwpd1VwLPQvuCRr7BPTs8wvqrv2gexxViwj96czT",
"D": "xpub6DK1vnTBe9EkhLACJRvovv8RSUC3MSiEV64opM7XUqrowxQ8J5C2WpA6n4vt5LS3bs618aKzi7k5w7VzNCv3SfqEeSepvvHaPhRoTvRqR5u",
"E": "xpub6CqbQjHN7r68GHh7RsiAyrdAmyiZQgWvDxQtba2NxZHumvfMK31U6emVQSexYrTAHWQeLygRD1yXZQLsCs1LLJtaeSxMAnh2YUmP3ov6EQz",
"F": "xpub6CRDxB1aHVNHfqjPeYhnPBhBfkQb4b4K581uYKxwv4KnkiVsRttBCXSkZM5jtP1Vv2v3wr5FxfzqWWDApLCbutBLnfwYpkWpZUmZSp6hqg5",
"G": "xpub6DGKmAKYDF44KQEaqXY3bbJNufEDi6QPnahV4JdBxFbFCN9Vg7ZfUHxPv3uhjeeJEtPe2PjFKWRsUrEF3RDttnXf9wXq3BfYBZemwKipJ24",
"H": "xpub6Bg8zbY94d1cBbAGT2crZL7C1UM8JWCP5CCtiHMnV4tB1pE9oCfjvZxRRFLi6EiamBDyCs3ARaHwU2FLx76YYCPFRVc1YyJi6depNtWRnoJ",
"I": "xpub6DMpHuTZTTN64eEHcNpyeQwehXgWTrY668ZkRWnRfkFEGKpNv2uPR3js1dJgcFRksSmrdtpHqFDPTzFsR1HqvzNdgZwXmk9vCLt1ypwUzA3",
"J": "xpub6CVeYPTG57D4tm9BvwCcakppwGJstbXyK8Yd611agusZuHmx7og3dNvr6pjMN6e4BoaNc5MZA4TjMLjMT2h2vJRU8rYLvHFUwrEL9zDbuqe",
}
XPUB_PASSPHRASE_NONE = "xpub6BiVtCpG9fQPxnPmHXG8PhtzQdWC2Su4qWu6XW9tpWFYhxydCLJGrWBJZ5H6qTAHdPQ7pQhtpjiYZVZARo14qHiay2fvrX996oEP42u8wZy"
XPUB_CARDANO_PASSPHRASE_A = "d37eba66d6183547b11b4d0c3e08e761da9f07c3ef32183f8b79360b2b66850e47e8eb3865251784c3c471a854ee40dfc067f7f3afe47d093388ea45239606fd"
XPUB_CARDANO_PASSPHRASE_B = "d80e770f6dfc3edb58eaab68aa091b2c27b08a47583471e93437ac5f8baa61880c7af4938a941c084c19731e6e57a5710e6ad1196263291aea297ce0eec0f177"
ADDRESS_N = parse_path("m/44h/0h/0h")
XPUB_REQUEST = messages.GetPublicKey(address_n=ADDRESS_N, coin_name="Bitcoin")
SESSIONS_STORED = 10
def _init_session(client: Client, session_id=None, derive_cardano=False):
"""Call Initialize, check and return the session ID."""
response = client.call(
messages.Initialize(session_id=session_id, derive_cardano=derive_cardano)
)
assert isinstance(response, messages.Features)
assert len(response.session_id) == 32
return response.session_id
def _get_xpub(client: Client, passphrase=None):
"""Get XPUB and check that the appropriate passphrase flow has happened."""
if passphrase is not None:
expected_responses = [
messages.PassphraseRequest,
messages.ButtonRequest,
messages.ButtonRequest,
messages.PublicKey,
]
else:
expected_responses = [messages.PublicKey]
with client:
client.use_passphrase(passphrase or "")
client.set_expected_responses(expected_responses)
result = client.call(XPUB_REQUEST)
return result.xpub
@pytest.mark.setup_client(passphrase=True)
def test_session_with_passphrase(client: Client):
# Let's start the communication by calling Initialize.
session_id = _init_session(client)
# GetPublicKey requires passphrase and since it is not cached,
# Trezor will prompt for it.
assert _get_xpub(client, passphrase="A") == XPUB_PASSPHRASES["A"]
# Call Initialize again, this time with the received session id and then call
# GetPublicKey. The passphrase should be cached now so Trezor must
# not ask for it again, whilst returning the same xpub.
new_session_id = _init_session(client, session_id=session_id)
assert new_session_id == session_id
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["A"]
# If we set session id in Initialize to None, the cache will be cleared
# and Trezor will ask for the passphrase again.
new_session_id = _init_session(client)
assert new_session_id != session_id
assert _get_xpub(client, passphrase="A") == XPUB_PASSPHRASES["A"]
# Unknown session id is the same as setting it to None.
_init_session(client, session_id=b"X" * 32)
assert _get_xpub(client, passphrase="A") == XPUB_PASSPHRASES["A"]
@pytest.mark.setup_client(passphrase=True)
def test_multiple_sessions(client: Client):
# start SESSIONS_STORED sessions
session_ids = []
for _ in range(SESSIONS_STORED):
session_ids.append(_init_session(client))
# Resume each session
for session_id in session_ids:
new_session_id = _init_session(client, session_id)
assert session_id == new_session_id
# Creating a new session replaces the least-recently-used session
_init_session(client)
# Resuming session 1 through SESSIONS_STORED will still work
for session_id in session_ids[1:]:
new_session_id = _init_session(client, session_id)
assert session_id == new_session_id
# Resuming session 0 will not work
new_session_id = _init_session(client, session_ids[0])
assert new_session_id != session_ids[0]
# New session bumped out the least-recently-used anonymous session.
# Resuming session 1 through SESSIONS_STORED will still work
for session_id in session_ids[1:]:
new_session_id = _init_session(client, session_id)
assert session_id == new_session_id
# Creating a new session replaces session_ids[0] again
_init_session(client)
# Resuming all sessions one by one will in turn bump out the previous session.
for session_id in session_ids:
new_session_id = _init_session(client, session_id)
assert session_id != new_session_id
@pytest.mark.setup_client(passphrase=True)
def test_multiple_passphrases(client: Client):
# start a session
session_a = _init_session(client)
assert _get_xpub(client, passphrase="A") == XPUB_PASSPHRASES["A"]
# start it again wit the same session id
new_session_id = _init_session(client, session_id=session_a)
# session is the same
assert new_session_id == session_a
# passphrase is not prompted
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["A"]
# start a second session
session_b = _init_session(client)
# new session -> new session id and passphrase prompt
assert _get_xpub(client, passphrase="B") == XPUB_PASSPHRASES["B"]
# provide the same session id -> must not ask for passphrase again.
new_session_id = _init_session(client, session_id=session_b)
assert new_session_id == session_b
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["B"]
# provide the first session id -> must not ask for passphrase again and return the same result.
new_session_id = _init_session(client, session_id=session_a)
assert new_session_id == session_a
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["A"]
# provide the second session id -> must not ask for passphrase again and return the same result.
new_session_id = _init_session(client, session_id=session_b)
assert new_session_id == session_b
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["B"]
@pytest.mark.slow
@pytest.mark.setup_client(passphrase=True)
def test_max_sessions_with_passphrases(client: Client):
# for the following tests, we are using as many passphrases as there are available sessions
assert len(XPUB_PASSPHRASES) == SESSIONS_STORED
# start as many sessions as the limit is
session_ids = {}
for passphrase, xpub in XPUB_PASSPHRASES.items():
session_id = _init_session(client)
assert session_id not in session_ids.values()
session_ids[passphrase] = session_id
assert _get_xpub(client, passphrase=passphrase) == xpub
# passphrase is not prompted for the started the sessions, regardless the order
# let's try 20 different orderings
passphrases = list(XPUB_PASSPHRASES.keys())
shuffling = passphrases[:]
for _ in range(20):
random.shuffle(shuffling)
for passphrase in shuffling:
session_id = _init_session(client, session_id=session_ids[passphrase])
assert session_id == session_ids[passphrase]
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES[passphrase]
# make sure the usage order is the reverse of the creation order
for passphrase in reversed(passphrases):
session_id = _init_session(client, session_id=session_ids[passphrase])
assert session_id == session_ids[passphrase]
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES[passphrase]
# creating one more session will exceed the limit
_init_session(client)
# new session asks for passphrase
_get_xpub(client, passphrase="XX")
# restoring the sessions in reverse will evict the next-up session
for passphrase in reversed(passphrases):
_init_session(client, session_id=session_ids[passphrase])
_get_xpub(client, passphrase="whatever") # passphrase is prompted
def test_session_enable_passphrase(client: Client):
# Let's start the communication by calling Initialize.
session_id = _init_session(client)
# Trezor will not prompt for passphrase because it is turned off.
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASE_NONE
# Turn on passphrase.
# Emit the call explicitly to avoid ClearSession done by the library function
response = client.call(messages.ApplySettings(use_passphrase=True))
assert isinstance(response, messages.Success)
# The session id is unchanged, therefore we do not prompt for the passphrase.
new_session_id = _init_session(client, session_id=session_id)
assert session_id == new_session_id
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASE_NONE
# We clear the session id now, so the passphrase should be asked.
new_session_id = _init_session(client)
assert session_id != new_session_id
assert _get_xpub(client, passphrase="A") == XPUB_PASSPHRASES["A"]
@pytest.mark.models("core")
@pytest.mark.setup_client(passphrase=True)
def test_passphrase_on_device(client: Client):
_init_session(client)
# try to get xpub with passphrase on host:
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PassphraseRequest)
# using `client.call` to auto-skip subsequent ButtonRequests for "show passphrase"
response = client.call(messages.PassphraseAck(passphrase="A", on_device=False))
assert isinstance(response, messages.PublicKey)
assert response.xpub == XPUB_PASSPHRASES["A"]
# try to get xpub again, passphrase should be cached
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PublicKey)
assert response.xpub == XPUB_PASSPHRASES["A"]
# make a new session
_init_session(client)
# try to get xpub with passphrase on device:
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PassphraseRequest)
response = client.call_raw(messages.PassphraseAck(on_device=True))
# no "show passphrase" here
assert isinstance(response, messages.ButtonRequest)
client.debug.input("A")
response = client.call_raw(messages.ButtonAck())
assert isinstance(response, messages.PublicKey)
assert response.xpub == XPUB_PASSPHRASES["A"]
# try to get xpub again, passphrase should be cached
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PublicKey)
assert response.xpub == XPUB_PASSPHRASES["A"]
@pytest.mark.models("core")
@pytest.mark.setup_client(passphrase=True)
def test_passphrase_always_on_device(client: Client):
# Let's start the communication by calling Initialize.
session_id = _init_session(client)
# Force passphrase entry on Trezor.
response = client.call(messages.ApplySettings(passphrase_always_on_device=True))
assert isinstance(response, messages.Success)
# Since we enabled the always_on_device setting, Trezor will send ButtonRequests and ask for it on the device.
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.ButtonRequest)
client.debug.input("") # Input empty passphrase.
response = client.call_raw(messages.ButtonAck())
assert isinstance(response, messages.PublicKey)
assert response.xpub == XPUB_PASSPHRASE_NONE
# Passphrase will not be prompted. The session id stays the same and the passphrase is cached.
_init_session(client, session_id=session_id)
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PublicKey)
assert response.xpub == XPUB_PASSPHRASE_NONE
# In case we want to add a new passphrase we need to send session_id = None.
_init_session(client)
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.ButtonRequest)
client.debug.input("A") # Input non-empty passphrase.
response = client.call_raw(messages.ButtonAck())
assert isinstance(response, messages.PublicKey)
assert response.xpub == XPUB_PASSPHRASES["A"]
@pytest.mark.models("legacy")
@pytest.mark.setup_client(passphrase="")
def test_passphrase_on_device_not_possible_on_t1(client: Client):
# This setting makes no sense on T1.
response = client.call_raw(messages.ApplySettings(passphrase_always_on_device=True))
assert isinstance(response, messages.Failure)
assert response.code == FailureType.DataError
# T1 should not accept on_device request
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PassphraseRequest)
response = client.call_raw(messages.PassphraseAck(on_device=True))
assert isinstance(response, messages.Failure)
assert response.code == FailureType.DataError
@pytest.mark.setup_client(passphrase=True)
def test_passphrase_ack_mismatch(client: Client):
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PassphraseRequest)
response = client.call_raw(messages.PassphraseAck(passphrase="A", on_device=True))
assert isinstance(response, messages.Failure)
assert response.code == FailureType.DataError
@pytest.mark.setup_client(passphrase="")
def test_passphrase_missing(client: Client):
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PassphraseRequest)
response = client.call_raw(messages.PassphraseAck(passphrase=None))
assert isinstance(response, messages.Failure)
assert response.code == FailureType.DataError
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PassphraseRequest)
response = client.call_raw(messages.PassphraseAck(passphrase=None, on_device=False))
assert isinstance(response, messages.Failure)
assert response.code == FailureType.DataError
@pytest.mark.setup_client(passphrase=True)
def test_passphrase_length(client: Client):
def call(passphrase: str, expected_result: bool):
_init_session(client)
response = client.call_raw(XPUB_REQUEST)
assert isinstance(response, messages.PassphraseRequest)
try:
response = client.call(messages.PassphraseAck(passphrase=passphrase))
assert expected_result is True, "Call should have failed"
assert isinstance(response, messages.PublicKey)
except exceptions.TrezorFailure as e:
assert expected_result is False, "Call should have succeeded"
assert e.code == FailureType.DataError
# 50 is ok
call(passphrase="A" * 50, expected_result=True)
# 51 is not
call(passphrase="A" * 51, expected_result=False)
# "š" has two bytes - 48x A and "š" should be fine (50 bytes)
call(passphrase="A" * 48 + "š", expected_result=True)
# "š" has two bytes - 49x A and "š" should not (51 bytes)
call(passphrase="A" * 49 + "š", expected_result=False)
@pytest.mark.models("core")
@pytest.mark.setup_client(passphrase=True)
def test_hide_passphrase_from_host(client: Client):
# Without safety checks, turning it on fails
with pytest.raises(TrezorFailure, match="Safety checks are strict"), client:
device.apply_settings(client, hide_passphrase_from_host=True)
device.apply_settings(client, safety_checks=SafetyCheckLevel.PromptTemporarily)
# Turning it on
device.apply_settings(client, hide_passphrase_from_host=True)
passphrase = "abc"
with client:
def input_flow():
yield
content = client.debug.read_layout().text_content().lower()
assert any(
(s[:50].lower() in content)
for s in TR.translate("passphrase__from_host_not_shown")
)
if client.layout_type in (LayoutType.TT, LayoutType.Mercury):
client.debug.press_yes()
elif client.layout_type is LayoutType.TR:
client.debug.press_right()
client.debug.press_right()
client.debug.press_yes()
else:
raise KeyError
client.watch_layout()
client.set_input_flow(input_flow)
client.set_expected_responses(
[
messages.PassphraseRequest,
messages.ButtonRequest,
messages.PublicKey,
]
)
client.use_passphrase(passphrase)
result = client.call(XPUB_REQUEST)
assert isinstance(result, messages.PublicKey)
xpub_hidden_passphrase = result.xpub
# Turning it off
device.apply_settings(client, hide_passphrase_from_host=False)
# Starting new session, otherwise the passphrase would be cached
_init_session(client)
with client:
def input_flow():
yield
TR.assert_in(
client.debug.read_layout().text_content(),
"passphrase__next_screen_will_show_passphrase",
)
client.debug.press_yes()
yield
TR.assert_equals(
client.debug.read_layout().title(),
"passphrase__title_confirm",
)
assert passphrase in client.debug.read_layout().text_content()
client.debug.press_yes()
client.watch_layout()
client.set_input_flow(input_flow)
client.set_expected_responses(
[
messages.PassphraseRequest,
messages.ButtonRequest,
messages.ButtonRequest,
messages.PublicKey,
]
)
client.use_passphrase(passphrase)
result = client.call(XPUB_REQUEST)
assert isinstance(result, messages.PublicKey)
xpub_shown_passphrase = result.xpub
assert xpub_hidden_passphrase == xpub_shown_passphrase
def _get_xpub_cardano(client: Client, passphrase):
msg = messages.CardanoGetPublicKey(
address_n=parse_path("m/44h/1815h/0h/0/0"),
derivation_type=messages.CardanoDerivationType.ICARUS,
)
response = client.call_raw(msg)
if passphrase is not None:
assert isinstance(response, messages.PassphraseRequest)
response = client.call(messages.PassphraseAck(passphrase=passphrase))
assert isinstance(response, messages.CardanoPublicKey)
return response.xpub
@pytest.mark.models("core")
@pytest.mark.altcoin
@pytest.mark.setup_client(passphrase=True)
def test_cardano_passphrase(client: Client):
# Cardano has a separate derivation method that needs to access the plaintext
# of the passphrase.
# Historically, Cardano calls would ask for passphrase again. Now, they should not.
session_id = _init_session(client, derive_cardano=True)
# GetPublicKey requires passphrase and since it is not cached,
# Trezor will prompt for it.
assert _get_xpub(client, passphrase="B") == XPUB_PASSPHRASES["B"]
# The passphrase is now cached for non-Cardano coins.
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["B"]
# The passphrase should be cached for Cardano as well
assert _get_xpub_cardano(client, passphrase=None) == XPUB_CARDANO_PASSPHRASE_B
# Initialize with the session id does not destroy the state
_init_session(client, session_id=session_id, derive_cardano=True)
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["B"]
assert _get_xpub_cardano(client, passphrase=None) == XPUB_CARDANO_PASSPHRASE_B
# New session will destroy the state
_init_session(client, derive_cardano=True)
# Cardano must ask for passphrase again
assert _get_xpub_cardano(client, passphrase="A") == XPUB_CARDANO_PASSPHRASE_A
# Passphrase is now cached for Cardano
assert _get_xpub_cardano(client, passphrase=None) == XPUB_CARDANO_PASSPHRASE_A
# Passphrase is cached for non-Cardano coins too
assert _get_xpub(client, passphrase=None) == XPUB_PASSPHRASES["A"]