feat(core): add ability to request backups with any number of groups/shares

mmilata/ui-t3t1-preview
Ioan Bizău 2 months ago committed by obrusvit
parent 7b1b865418
commit 891927f6b3

@ -401,6 +401,12 @@ message ResetDevice {
* @next Success
*/
message BackupDevice {
optional uint32 group_threshold = 1;
message Slip39Group {
required uint32 member_threshold = 1;
required uint32 member_count = 2;
}
repeated Slip39Group groups = 2;
}
/**

@ -0,0 +1 @@
Added ability to request Shamir backups with any number of groups/shares.

@ -1,9 +1,14 @@
from typing import TYPE_CHECKING
from trezor.enums import BackupType
if TYPE_CHECKING:
from trezor.messages import BackupDevice, Success
BAK_T_BIP39 = BackupType.Bip39 # global_import_cache
async def backup_device(msg: BackupDevice) -> Success:
import storage.device as storage_device
from trezor import wire
@ -11,21 +16,37 @@ async def backup_device(msg: BackupDevice) -> Success:
from apps.common import mnemonic
from .reset_device import backup_seed, layout
from .reset_device import backup_seed, backup_slip39_custom, layout
if not storage_device.is_initialized():
raise wire.NotInitialized("Device is not initialized")
if not storage_device.needs_backup():
raise wire.ProcessError("Seed already backed up")
mnemonic_secret, mnemonic_type = mnemonic.get()
mnemonic_secret, backup_type = mnemonic.get()
if mnemonic_secret is None:
raise RuntimeError
group_threshold = msg.group_threshold
groups = [(g.member_threshold, g.member_count) for g in msg.groups]
if group_threshold is not None:
if group_threshold < 1:
raise wire.DataError("group_threshold must be a positive integer")
if len(groups) < group_threshold:
raise wire.DataError("Not enough groups provided for group_threshold")
if backup_type == BAK_T_BIP39:
raise wire.ProcessError("Expected SLIP39 backup")
elif len(groups) > 0:
raise wire.DataError("group_threshold is missing")
storage_device.set_unfinished_backup(True)
storage_device.set_backed_up()
await backup_seed(mnemonic_type, mnemonic_secret)
if group_threshold is not None:
await backup_slip39_custom(mnemonic_secret, group_threshold, groups)
else:
await backup_seed(backup_type, mnemonic_secret)
storage_device.set_unfinished_backup(False)

@ -1,9 +1,11 @@
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Sequence
import storage
import storage.device as storage_device
from trezor import TR
from trezor.crypto import slip39
from trezor.enums import BackupType
from trezor.ui.layouts import confirm_action
from trezor.wire import ProcessError
from . import layout
@ -111,31 +113,19 @@ async def reset_device(msg: ResetDevice) -> Success:
async def _backup_slip39_basic(encrypted_master_secret: bytes) -> None:
group_threshold = 1
# get number of shares
await layout.slip39_show_checklist(0, BAK_T_SLIP39_BASIC)
shares_count = await layout.slip39_prompt_number_of_shares()
share_count = await layout.slip39_prompt_number_of_shares()
# get threshold
await layout.slip39_show_checklist(1, BAK_T_SLIP39_BASIC)
threshold = await layout.slip39_prompt_threshold(shares_count)
identifier = storage_device.get_slip39_identifier()
iteration_exponent = storage_device.get_slip39_iteration_exponent()
if identifier is None or iteration_exponent is None:
raise ValueError
# generate the mnemonics
mnemonics = slip39.split_ems(
1, # Single Group threshold
[(threshold, shares_count)], # Single Group threshold/count
identifier,
iteration_exponent,
encrypted_master_secret,
)[0]
share_threshold = await layout.slip39_prompt_threshold(share_count)
# show and confirm individual shares
await layout.slip39_show_checklist(2, BAK_T_SLIP39_BASIC)
await layout.slip39_basic_show_and_confirm_shares(mnemonics)
await backup_slip39_custom(
encrypted_master_secret, group_threshold, ((share_threshold, share_count),)
)
async def _backup_slip39_advanced(encrypted_master_secret: bytes) -> None:
@ -155,6 +145,40 @@ async def _backup_slip39_advanced(encrypted_master_secret: bytes) -> None:
share_threshold = await layout.slip39_prompt_threshold(share_count, i)
groups.append((share_threshold, share_count))
await backup_slip39_custom(encrypted_master_secret, group_threshold, groups)
async def backup_slip39_custom(
encrypted_master_secret: bytes,
group_threshold: int,
groups: Sequence[tuple[int, int]],
) -> None:
mnemonics = await _get_slip39_mnemonics(encrypted_master_secret, group_threshold, groups)
# show and confirm individual shares
if len(groups) == 1 and groups[0][0] == 1 and groups[0][1] == 1:
# for a single 1-of-1 group, we use the same layouts as for BIP39
await layout.show_and_confirm_mnemonic(mnemonics[0][0])
else:
await confirm_action(
"warning_shamir_backup",
TR.reset__title_shamir_backup,
description=TR.reset__create_x_of_y_shamir_backup_template.format(
groups[0][0], groups[0][1]
),
verb=TR.buttons__continue,
)
if len(groups) == 1:
await layout.slip39_basic_show_and_confirm_shares(mnemonics[0])
else:
await layout.slip39_advanced_show_and_confirm_shares(mnemonics)
async def _get_slip39_mnemonics(
encrypted_master_secret: bytes,
group_threshold: int,
groups: Sequence[tuple[int, int]],
):
identifier = storage_device.get_slip39_identifier()
iteration_exponent = storage_device.get_slip39_iteration_exponent()
if identifier is None or iteration_exponent is None:
@ -170,7 +194,11 @@ async def _backup_slip39_advanced(encrypted_master_secret: bytes) -> None:
)
# show and confirm individual shares
await layout.slip39_advanced_show_and_confirm_shares(mnemonics)
if len(groups) == 1:
await layout.slip39_show_checklist(2, BAK_T_SLIP39_BASIC)
await layout.slip39_basic_show_and_confirm_shares(mnemonics[0])
else:
await layout.slip39_advanced_show_and_confirm_shares(mnemonics)
def _validate_reset_device(msg: ResetDevice) -> None:
@ -184,7 +212,7 @@ def _validate_reset_device(msg: ResetDevice) -> None:
BAK_T_SLIP39_BASIC,
BAK_T_SLIP39_ADVANCED,
):
raise ProcessError("Backup type not implemented.")
raise ProcessError("Backup type not implemented")
if backup_types.is_slip39_backup_type(backup_type):
if msg.strength not in (128, 256):
raise ProcessError("Invalid strength (has to be 128 or 256 bits)")

@ -38,7 +38,7 @@ from trezor.crypto import random
from trezor.errors import MnemonicError
if TYPE_CHECKING:
from typing import Callable, Iterable
from typing import Callable, Collection, Iterable
Indices = tuple[int, ...]
MnemonicGroups = dict[int, tuple[int, set[tuple[int, bytes]]]]
@ -174,7 +174,9 @@ def generate_random_identifier() -> int:
def split_ems(
group_threshold: int, # The number of groups required to reconstruct the master secret.
groups: list[tuple[int, int]], # A list of (member_threshold, member_count).
groups: Collection[
tuple[int, int]
], # A collection of (member_threshold, member_count).
identifier: int,
iteration_exponent: int,
encrypted_master_secret: bytes, # The encrypted master secret to split.

@ -2532,6 +2532,16 @@ if TYPE_CHECKING:
return isinstance(msg, cls)
class BackupDevice(protobuf.MessageType):
group_threshold: "int | None"
groups: "list[Slip39Group]"
def __init__(
self,
*,
groups: "list[Slip39Group] | None" = None,
group_threshold: "int | None" = None,
) -> None:
pass
@classmethod
def is_type_of(cls, msg: Any) -> TypeGuard["BackupDevice"]:
@ -2745,6 +2755,22 @@ if TYPE_CHECKING:
def is_type_of(cls, msg: Any) -> TypeGuard["UnlockBootloader"]:
return isinstance(msg, cls)
class Slip39Group(protobuf.MessageType):
member_threshold: "int"
member_count: "int"
def __init__(
self,
*,
member_threshold: "int",
member_count: "int",
) -> None:
pass
@classmethod
def is_type_of(cls, msg: Any) -> TypeGuard["Slip39Group"]:
return isinstance(msg, cls)
class DebugLinkDecision(protobuf.MessageType):
button: "DebugButton | None"
swipe: "DebugSwipeDirection | None"

@ -26,6 +26,8 @@ LoadDevice.label max_size:33
ResetDevice.language max_size:17
ResetDevice.label max_size:33
BackupDevice.groups type:FT_IGNORE
Entropy.entropy max_size:1024
EntropyAck.entropy max_size:128

@ -0,0 +1 @@
Added ability to request Shamir backups with any number of groups/shares.

@ -16,7 +16,7 @@
import secrets
import sys
from typing import TYPE_CHECKING, Optional, Sequence
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
import click
@ -237,10 +237,17 @@ def setup(
@cli.command()
@click.option("-t", "--group-threshold", type=int)
@click.option("-g", "--group", "groups", type=(int, int), multiple=True, metavar="T N")
@with_client
def backup(client: "TrezorClient") -> str:
def backup(
client: "TrezorClient",
group_threshold: Optional[int] = None,
groups: Sequence[Tuple[int, int]] = (),
) -> str:
"""Perform device seed backup."""
return device.backup(client)
return device.backup(client, group_threshold, groups)
@cli.command()

@ -19,7 +19,7 @@ from __future__ import annotations
import os
import time
import warnings
from typing import TYPE_CHECKING, Callable, Optional
from typing import TYPE_CHECKING, Callable, Iterable, Optional
from . import messages
from .exceptions import Cancelled, TrezorException
@ -268,8 +268,20 @@ def reset(
@expect(messages.Success, field="message", ret_type=str)
@session
def backup(client: "TrezorClient") -> "MessageType":
ret = client.call(messages.BackupDevice())
def backup(
client: "TrezorClient",
group_threshold: Optional[int] = None,
groups: Iterable[tuple[int, int]] = (),
) -> "MessageType":
ret = client.call(
messages.BackupDevice(
group_threshold=group_threshold,
groups=[
messages.Slip39Group(member_threshold=t, member_count=c)
for t, c in groups
],
)
)
client.refresh_features()
return ret

@ -3696,6 +3696,19 @@ class ResetDevice(protobuf.MessageType):
class BackupDevice(protobuf.MessageType):
MESSAGE_WIRE_TYPE = 34
FIELDS = {
1: protobuf.Field("group_threshold", "uint32", repeated=False, required=False, default=None),
2: protobuf.Field("groups", "Slip39Group", repeated=True, required=False, default=None),
}
def __init__(
self,
*,
groups: Optional[Sequence["Slip39Group"]] = None,
group_threshold: Optional["int"] = None,
) -> None:
self.groups: Sequence["Slip39Group"] = groups if groups is not None else []
self.group_threshold = group_threshold
class EntropyRequest(protobuf.MessageType):
@ -3903,6 +3916,23 @@ class UnlockBootloader(protobuf.MessageType):
MESSAGE_WIRE_TYPE = 96
class Slip39Group(protobuf.MessageType):
MESSAGE_WIRE_TYPE = None
FIELDS = {
1: protobuf.Field("member_threshold", "uint32", repeated=False, required=True),
2: protobuf.Field("member_count", "uint32", repeated=False, required=True),
}
def __init__(
self,
*,
member_threshold: "int",
member_count: "int",
) -> None:
self.member_threshold = member_threshold
self.member_count = member_count
class DebugLinkDecision(protobuf.MessageType):
MESSAGE_WIRE_TYPE = 100
FIELDS = {

@ -7135,6 +7135,11 @@ impl ::protobuf::reflect::ProtobufValue for ResetDevice {
// @@protoc_insertion_point(message:hw.trezor.messages.management.BackupDevice)
#[derive(PartialEq,Clone,Default,Debug)]
pub struct BackupDevice {
// message fields
// @@protoc_insertion_point(field:hw.trezor.messages.management.BackupDevice.group_threshold)
pub group_threshold: ::std::option::Option<u32>,
// @@protoc_insertion_point(field:hw.trezor.messages.management.BackupDevice.groups)
pub groups: ::std::vec::Vec<backup_device::Slip39Group>,
// special fields
// @@protoc_insertion_point(special_field:hw.trezor.messages.management.BackupDevice.special_fields)
pub special_fields: ::protobuf::SpecialFields,
@ -7151,9 +7156,38 @@ impl BackupDevice {
::std::default::Default::default()
}
// optional uint32 group_threshold = 1;
pub fn group_threshold(&self) -> u32 {
self.group_threshold.unwrap_or(0)
}
pub fn clear_group_threshold(&mut self) {
self.group_threshold = ::std::option::Option::None;
}
pub fn has_group_threshold(&self) -> bool {
self.group_threshold.is_some()
}
// Param is passed by value, moved
pub fn set_group_threshold(&mut self, v: u32) {
self.group_threshold = ::std::option::Option::Some(v);
}
fn generated_message_descriptor_data() -> ::protobuf::reflect::GeneratedMessageDescriptorData {
let mut fields = ::std::vec::Vec::with_capacity(0);
let mut fields = ::std::vec::Vec::with_capacity(2);
let mut oneofs = ::std::vec::Vec::with_capacity(0);
fields.push(::protobuf::reflect::rt::v2::make_option_accessor::<_, _>(
"group_threshold",
|m: &BackupDevice| { &m.group_threshold },
|m: &mut BackupDevice| { &mut m.group_threshold },
));
fields.push(::protobuf::reflect::rt::v2::make_vec_simpler_accessor::<_, _>(
"groups",
|m: &BackupDevice| { &m.groups },
|m: &mut BackupDevice| { &mut m.groups },
));
::protobuf::reflect::GeneratedMessageDescriptorData::new_2::<BackupDevice>(
"BackupDevice",
fields,
@ -7166,12 +7200,23 @@ impl ::protobuf::Message for BackupDevice {
const NAME: &'static str = "BackupDevice";
fn is_initialized(&self) -> bool {
for v in &self.groups {
if !v.is_initialized() {
return false;
}
};
true
}
fn merge_from(&mut self, is: &mut ::protobuf::CodedInputStream<'_>) -> ::protobuf::Result<()> {
while let Some(tag) = is.read_raw_tag_or_eof()? {
match tag {
8 => {
self.group_threshold = ::std::option::Option::Some(is.read_uint32()?);
},
18 => {
self.groups.push(is.read_message()?);
},
tag => {
::protobuf::rt::read_unknown_or_skip_group(tag, is, self.special_fields.mut_unknown_fields())?;
},
@ -7184,12 +7229,25 @@ impl ::protobuf::Message for BackupDevice {
#[allow(unused_variables)]
fn compute_size(&self) -> u64 {
let mut my_size = 0;
if let Some(v) = self.group_threshold {
my_size += ::protobuf::rt::uint32_size(1, v);
}
for value in &self.groups {
let len = value.compute_size();
my_size += 1 + ::protobuf::rt::compute_raw_varint64_size(len) + len;
};
my_size += ::protobuf::rt::unknown_fields_size(self.special_fields.unknown_fields());
self.special_fields.cached_size().set(my_size as u32);
my_size
}
fn write_to_with_cached_sizes(&self, os: &mut ::protobuf::CodedOutputStream<'_>) -> ::protobuf::Result<()> {
if let Some(v) = self.group_threshold {
os.write_uint32(1, v)?;
}
for v in &self.groups {
::protobuf::rt::write_message_field_with_cached_size(2, v, os)?;
};
os.write_unknown_fields(self.special_fields.unknown_fields())?;
::std::result::Result::Ok(())
}
@ -7207,11 +7265,15 @@ impl ::protobuf::Message for BackupDevice {
}
fn clear(&mut self) {
self.group_threshold = ::std::option::Option::None;
self.groups.clear();
self.special_fields.clear();
}
fn default_instance() -> &'static BackupDevice {
static instance: BackupDevice = BackupDevice {
group_threshold: ::std::option::Option::None,
groups: ::std::vec::Vec::new(),
special_fields: ::protobuf::SpecialFields::new(),
};
&instance
@ -7235,6 +7297,193 @@ impl ::protobuf::reflect::ProtobufValue for BackupDevice {
type RuntimeType = ::protobuf::reflect::rt::RuntimeTypeMessage<Self>;
}
/// Nested message and enums of message `BackupDevice`
pub mod backup_device {
// @@protoc_insertion_point(message:hw.trezor.messages.management.BackupDevice.Slip39Group)
#[derive(PartialEq,Clone,Default,Debug)]
pub struct Slip39Group {
// message fields
// @@protoc_insertion_point(field:hw.trezor.messages.management.BackupDevice.Slip39Group.member_threshold)
pub member_threshold: ::std::option::Option<u32>,
// @@protoc_insertion_point(field:hw.trezor.messages.management.BackupDevice.Slip39Group.member_count)
pub member_count: ::std::option::Option<u32>,
// special fields
// @@protoc_insertion_point(special_field:hw.trezor.messages.management.BackupDevice.Slip39Group.special_fields)
pub special_fields: ::protobuf::SpecialFields,
}
impl<'a> ::std::default::Default for &'a Slip39Group {
fn default() -> &'a Slip39Group {
<Slip39Group as ::protobuf::Message>::default_instance()
}
}
impl Slip39Group {
pub fn new() -> Slip39Group {
::std::default::Default::default()
}
// required uint32 member_threshold = 1;
pub fn member_threshold(&self) -> u32 {
self.member_threshold.unwrap_or(0)
}
pub fn clear_member_threshold(&mut self) {
self.member_threshold = ::std::option::Option::None;
}
pub fn has_member_threshold(&self) -> bool {
self.member_threshold.is_some()
}
// Param is passed by value, moved
pub fn set_member_threshold(&mut self, v: u32) {
self.member_threshold = ::std::option::Option::Some(v);
}
// required uint32 member_count = 2;
pub fn member_count(&self) -> u32 {
self.member_count.unwrap_or(0)
}
pub fn clear_member_count(&mut self) {
self.member_count = ::std::option::Option::None;
}
pub fn has_member_count(&self) -> bool {
self.member_count.is_some()
}
// Param is passed by value, moved
pub fn set_member_count(&mut self, v: u32) {
self.member_count = ::std::option::Option::Some(v);
}
pub(in super) fn generated_message_descriptor_data() -> ::protobuf::reflect::GeneratedMessageDescriptorData {
let mut fields = ::std::vec::Vec::with_capacity(2);
let mut oneofs = ::std::vec::Vec::with_capacity(0);
fields.push(::protobuf::reflect::rt::v2::make_option_accessor::<_, _>(
"member_threshold",
|m: &Slip39Group| { &m.member_threshold },
|m: &mut Slip39Group| { &mut m.member_threshold },
));
fields.push(::protobuf::reflect::rt::v2::make_option_accessor::<_, _>(
"member_count",
|m: &Slip39Group| { &m.member_count },
|m: &mut Slip39Group| { &mut m.member_count },
));
::protobuf::reflect::GeneratedMessageDescriptorData::new_2::<Slip39Group>(
"BackupDevice.Slip39Group",
fields,
oneofs,
)
}
}
impl ::protobuf::Message for Slip39Group {
const NAME: &'static str = "Slip39Group";
fn is_initialized(&self) -> bool {
if self.member_threshold.is_none() {
return false;
}
if self.member_count.is_none() {
return false;
}
true
}
fn merge_from(&mut self, is: &mut ::protobuf::CodedInputStream<'_>) -> ::protobuf::Result<()> {
while let Some(tag) = is.read_raw_tag_or_eof()? {
match tag {
8 => {
self.member_threshold = ::std::option::Option::Some(is.read_uint32()?);
},
16 => {
self.member_count = ::std::option::Option::Some(is.read_uint32()?);
},
tag => {
::protobuf::rt::read_unknown_or_skip_group(tag, is, self.special_fields.mut_unknown_fields())?;
},
};
}
::std::result::Result::Ok(())
}
// Compute sizes of nested messages
#[allow(unused_variables)]
fn compute_size(&self) -> u64 {
let mut my_size = 0;
if let Some(v) = self.member_threshold {
my_size += ::protobuf::rt::uint32_size(1, v);
}
if let Some(v) = self.member_count {
my_size += ::protobuf::rt::uint32_size(2, v);
}
my_size += ::protobuf::rt::unknown_fields_size(self.special_fields.unknown_fields());
self.special_fields.cached_size().set(my_size as u32);
my_size
}
fn write_to_with_cached_sizes(&self, os: &mut ::protobuf::CodedOutputStream<'_>) -> ::protobuf::Result<()> {
if let Some(v) = self.member_threshold {
os.write_uint32(1, v)?;
}
if let Some(v) = self.member_count {
os.write_uint32(2, v)?;
}
os.write_unknown_fields(self.special_fields.unknown_fields())?;
::std::result::Result::Ok(())
}
fn special_fields(&self) -> &::protobuf::SpecialFields {
&self.special_fields
}
fn mut_special_fields(&mut self) -> &mut ::protobuf::SpecialFields {
&mut self.special_fields
}
fn new() -> Slip39Group {
Slip39Group::new()
}
fn clear(&mut self) {
self.member_threshold = ::std::option::Option::None;
self.member_count = ::std::option::Option::None;
self.special_fields.clear();
}
fn default_instance() -> &'static Slip39Group {
static instance: Slip39Group = Slip39Group {
member_threshold: ::std::option::Option::None,
member_count: ::std::option::Option::None,
special_fields: ::protobuf::SpecialFields::new(),
};
&instance
}
}
impl ::protobuf::MessageFull for Slip39Group {
fn descriptor() -> ::protobuf::reflect::MessageDescriptor {
static descriptor: ::protobuf::rt::Lazy<::protobuf::reflect::MessageDescriptor> = ::protobuf::rt::Lazy::new();
descriptor.get(|| super::file_descriptor().message_by_package_relative_name("BackupDevice.Slip39Group").unwrap()).clone()
}
}
impl ::std::fmt::Display for Slip39Group {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
::protobuf::text_format::fmt(self, f)
}
}
impl ::protobuf::reflect::ProtobufValue for Slip39Group {
type RuntimeType = ::protobuf::reflect::rt::RuntimeTypeMessage<Self>;
}
}
// @@protoc_insertion_point(message:hw.trezor.messages.management.EntropyRequest)
#[derive(PartialEq,Clone,Default,Debug)]
pub struct EntropyRequest {
@ -10605,7 +10854,7 @@ pub fn file_descriptor() -> &'static ::protobuf::reflect::FileDescriptor {
let generated_file_descriptor = generated_file_descriptor_lazy.get(|| {
let mut deps = ::std::vec::Vec::with_capacity(1);
deps.push(super::messages::file_descriptor().clone());
let mut messages = ::std::vec::Vec::with_capacity(44);
let mut messages = ::std::vec::Vec::with_capacity(45);
messages.push(Initialize::generated_message_descriptor_data());
messages.push(GetFeatures::generated_message_descriptor_data());
messages.push(Features::generated_message_descriptor_data());
@ -10650,6 +10899,7 @@ pub fn file_descriptor() -> &'static ::protobuf::reflect::FileDescriptor {
messages.push(UnlockedPathRequest::generated_message_descriptor_data());
messages.push(ShowDeviceTutorial::generated_message_descriptor_data());
messages.push(UnlockBootloader::generated_message_descriptor_data());
messages.push(backup_device::Slip39Group::generated_message_descriptor_data());
let mut enums = ::std::vec::Vec::with_capacity(8);
enums.push(BackupType::generated_enum_descriptor_data());
enums.push(SafetyCheckLevel::generated_enum_descriptor_data());

@ -38,6 +38,13 @@ def confirm_read(debug: "DebugLink", middle_r: bool = False) -> None:
debug.press_right(wait=True)
def cancel_backup(debug: "DebugLink", middle_r: bool = False) -> None:
if debug.model in (models.T2T1, models.T3T1):
debug.click(buttons.CANCEL, wait=True)
elif debug.model in (models.T2B1,):
debug.press_left(wait=True)
def set_selection(debug: "DebugLink", button: tuple[int, int], diff: int) -> None:
if debug.model in (models.T2T1, models.T3T1):
assert "NumberInputDialog" in debug.read_layout().all_components()

@ -0,0 +1,118 @@
# This file is part of the Trezor project.
#
# Copyright (C) 2012-2024 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>.
from typing import TYPE_CHECKING
import pytest
from trezorlib import device, messages
from ..common import EXTERNAL_ENTROPY, WITH_MOCK_URANDOM, generate_entropy
from . import reset
if TYPE_CHECKING:
from ..device_handler import BackgroundDeviceHandler
pytestmark = [pytest.mark.skip_t1b1]
@pytest.mark.parametrize(
"group_threshold, share_threshold, share_count",
[
pytest.param(1, 1, 1, id="1of1"),
pytest.param(1, 2, 3, id="2of3"),
pytest.param(1, 5, 5, id="5of5"),
],
)
@pytest.mark.setup_client(uninitialized=True)
@WITH_MOCK_URANDOM
def test_backup_slip39_custom(
device_handler: "BackgroundDeviceHandler",
group_threshold: int,
share_threshold: int,
share_count: int,
):
features = device_handler.features()
debug = device_handler.debuglink()
assert features.initialized is False
device_handler.run(
device.reset,
strength=128,
backup_type=messages.BackupType.Slip39_Basic,
pin_protection=False,
)
# confirm new wallet
reset.confirm_new_wallet(debug)
# cancel back up
reset.cancel_backup(debug)
# confirm cancel
reset.cancel_backup(debug)
assert device_handler.result() == "Initialized"
device_handler.run(
device.backup,
group_threshold=group_threshold,
groups=[(share_threshold, share_count)],
)
# confirm checklist
reset.confirm_read(debug)
# confirm backup warning
reset.confirm_read(debug, middle_r=True)
if share_count > 1:
# confirm shamir warning
reset.confirm_read(debug, middle_r=True)
all_words: list[str] = []
for _ in range(share_count):
# read words
words = reset.read_words(debug, messages.BackupType.Slip39_Basic)
# confirm words
reset.confirm_words(debug, words)
# confirm share checked
reset.confirm_read(debug)
all_words.append(" ".join(words))
# confirm backup done
reset.confirm_read(debug)
# generate secret locally
internal_entropy = debug.state().reset_entropy
assert internal_entropy is not None
secret = generate_entropy(128, internal_entropy, EXTERNAL_ENTROPY)
# validate that all combinations will result in the correct master secret
reset.validate_mnemonics(all_words[:share_threshold], secret)
assert device_handler.result() == "Seed successfully backed up"
features = device_handler.features()
assert features.initialized is True
assert features.needs_backup is False
assert features.pin_protection is False
assert features.passphrase_protection is False
assert features.backup_type is messages.BackupType.Slip39_Basic

@ -58,6 +58,8 @@ MNEMONIC_SLIP39_ADVANCED_33 = [
"wildlife deal beard romp alcohol space mild usual clothes union nuclear testify course research heat listen task location thank hospital slice smell failure fawn helpful priest ambition average recover lecture process dough stadium",
"wildlife deal acrobat romp anxiety axis starting require metric flexible geology game drove editor edge screw helpful have huge holy making pitch unknown carve holiday numb glasses survive already tenant adapt goat fangs",
]
MNEMONIC_SLIP39_CUSTOM_1of1 = ["tolerate flexible academic academic average dwarf square home promise aspect temple cluster roster forward hand unfair tenant emperor ceramic element forget perfect knit adapt review usual formal receiver typical pleasure duke yield party"]
MNEMONIC_SLIP39_CUSTOM_SECRET = "3439316237393562383066633231636364663436366330666263393863386663"
# External entropy mocked as received from trezorlib.
EXTERNAL_ENTROPY = b"zlutoucky kun upel divoke ody" * 2
# fmt: on

@ -25,12 +25,15 @@ from trezorlib.exceptions import TrezorFailure
from ..common import (
MNEMONIC12,
MNEMONIC_SLIP39_ADVANCED_20,
MNEMONIC_SLIP39_CUSTOM_SECRET,
MNEMONIC_SLIP39_BASIC_20_3of6,
MNEMONIC_SLIP39_CUSTOM_1of1,
)
from ..input_flows import (
InputFlowBip39Backup,
InputFlowSlip39AdvancedBackup,
InputFlowSlip39BasicBackup,
InputFlowSlip39CustomBackup,
)
@ -111,6 +114,36 @@ def test_backup_slip39_advanced(client: Client, click_info: bool):
assert expected_ms == actual_ms
@pytest.mark.skip_t1b1
@pytest.mark.setup_client(needs_backup=True, mnemonic=MNEMONIC_SLIP39_CUSTOM_1of1[0])
@pytest.mark.parametrize(
"share_threshold,share_count",
[(1, 1), (2, 2), (3, 5)],
ids=["1_of_1", "2_of_2", "3_of_5"],
)
def test_backup_slip39_custom(client: Client, share_threshold, share_count):
assert client.features.needs_backup is True
with client:
IF = InputFlowSlip39CustomBackup(client, share_count)
client.set_input_flow(IF.get())
device.backup(
client, group_threshold=1, groups=[(share_threshold, share_count)]
)
client.init_device()
assert client.features.initialized is True
assert client.features.needs_backup is False
assert client.features.unfinished_backup is False
assert client.features.no_backup is False
assert len(IF.mnemonics) == share_count
assert (
shamir.combine_mnemonics(IF.mnemonics[-share_threshold:]).hex()
== MNEMONIC_SLIP39_CUSTOM_SECRET
)
# we only test this with bip39 because the code path is always the same
@pytest.mark.setup_client(no_backup=True)
def test_no_backup_fails(client: Client):

@ -1295,12 +1295,13 @@ class InputFlowBip39ResetFailedCheck(InputFlowBase):
self.debug.press_yes()
def load_5_shares(
def load_N_shares(
debug: DebugLink,
n: int,
) -> Generator[None, "messages.ButtonRequest", list[str]]:
mnemonics: list[str] = []
for _ in range(5):
for _ in range(n):
# Phrase screen
mnemonic = yield from read_and_confirm_mnemonic(debug)
assert mnemonic is not None
@ -1338,7 +1339,7 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
self.debug.press_yes()
# Mnemonic phrases
self.mnemonics = yield from load_5_shares(self.debug)
self.mnemonics = yield from load_N_shares(self.debug, 5)
br = yield # Confirm backup
assert br.code == B.Success
@ -1363,7 +1364,7 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
self.debug.press_yes()
# Mnemonic phrases
self.mnemonics = yield from load_5_shares(self.debug)
self.mnemonics = yield from load_N_shares(self.debug, 5)
br = yield # Confirm backup
assert br.code == B.Success
@ -1388,7 +1389,7 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
self.debug.press_yes()
# Mnemonic phrases
self.mnemonics = yield from load_5_shares(self.debug)
self.mnemonics = yield from load_N_shares(self.debug, 5)
br = yield # Confirm backup
assert br.code == B.Success
@ -1412,7 +1413,7 @@ class InputFlowSlip39BasicResetRecovery(InputFlowBase):
yield from click_through(self.debug, screens=8, code=B.ResetDevice)
# Mnemonic phrases
self.mnemonics = yield from load_5_shares(self.debug)
self.mnemonics = yield from load_N_shares(self.debug, 5)
br = yield # safety warning
assert br.code == B.Success
@ -1441,7 +1442,7 @@ class InputFlowSlip39BasicResetRecovery(InputFlowBase):
self.debug.press_yes()
# Mnemonic phrases
self.mnemonics = yield from load_5_shares(self.debug)
self.mnemonics = yield from load_N_shares(self.debug, 5)
br = yield # Confirm backup
assert br.code == B.Success
@ -1459,13 +1460,59 @@ class InputFlowSlip39BasicResetRecovery(InputFlowBase):
yield from click_through(self.debug, screens=8, code=B.ResetDevice)
# Mnemonic phrases
self.mnemonics = yield from load_5_shares(self.debug)
self.mnemonics = yield from load_N_shares(self.debug, 5)
br = yield # safety warning
assert br.code == B.Success
self.debug.press_yes()
class InputFlowSlip39CustomBackup(InputFlowBase):
def __init__(self, client: Client, share_count: int):
super().__init__(client)
self.mnemonics: list[str] = []
self.share_count = share_count
def input_flow_tt(self) -> BRGeneratorType:
yield # Checklist
self.debug.press_yes()
yield # Confirm show seeds
self.debug.press_yes()
# Mnemonic phrases
self.mnemonics = yield from load_N_shares(self.debug, self.share_count)
br = yield # Confirm backup
assert br.code == B.Success
self.debug.press_yes()
def input_flow_tr(self) -> BRGeneratorType:
yield # Checklist
self.debug.press_yes()
yield # Confirm show seeds
self.debug.press_yes()
# Mnemonic phrases
self.mnemonics = yield from load_N_shares(self.debug, self.share_count)
br = yield # Confirm backup
assert br.code == B.Success
self.debug.press_yes()
def input_flow_t3t1(self) -> BRGeneratorType:
yield # Checklist
self.debug.press_yes()
yield # Confirm show seeds
self.debug.press_yes()
# Mnemonic phrases
self.mnemonics = yield from load_N_shares(self.debug, self.share_count)
br = yield # Confirm backup
assert br.code == B.Success
self.debug.press_yes()
def load_5_groups_5_shares(
debug: DebugLink,
) -> Generator[None, "messages.ButtonRequest", list[str]]:

@ -835,21 +835,24 @@
"T3T1_en_test_autolock.py::test_dryrun_enter_word_slowly": "be557c3e4e0492d0b884d4ed549d3ae18e6175b74b5945e7ab6f2f96aab58748",
"T3T1_en_test_autolock.py::test_dryrun_locks_at_number_of_words": "b469764608c251771031e49a1856f859265fa3e571caa9288e8170e527b70607",
"T3T1_en_test_autolock.py::test_dryrun_locks_at_word_entry": "736652b5298a7a4ee2e51282323d1150c9844b6f7b738f421ac4ad3a83d0788d",
"T3T1_en_test_lock.py::test_hold_to_lock": "ddedcb889f91838b1a97b633518ebd007524525578bb606bf7411709ad43bb5c",
"T3T1_en_test_passphrase_tt.py::test_cycle_through_last_character": "569773f6337f42b951f8bc5f0d99adb84ab30c82b9bda390433d5fa7d4ef2a1a",
"T3T1_en_test_passphrase_tt.py::test_passphrase_click_same_button_many_times": "af63f79b8d467247d9de86535d8a3a13780bb703f61cb66a231df403b7d4f0e1",
"T3T1_en_test_passphrase_tt.py::test_passphrase_delete": "fead350ff70b836fe25f3930b4f7a6ffa3af18687cce2e1f51fd0da19b451b86",
"T3T1_en_test_passphrase_tt.py::test_passphrase_delete_all": "a8e3e01f5e252bda0573a17a3e049000d89f3f7769c3bbf6e5c4817a47e8b8cc",
"T3T1_en_test_passphrase_tt.py::test_passphrase_dollar_sign_deletion": "e339dfa3954a281375bcc2e3b0f82d66ccb0897ad1fd768bde5dc3cc9bc845b1",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[Y@14lw%p)JN@f54MYvys@zj'g-mnkoxeaMzLgfCxUdDSZW-78765865": "e5c6a2b2f06b1d47e1b373b9a146c7cd27def49ff8010d8c286e46b94a70ac40",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[abc 123-mvqzZUb9NaUc62Buk9WCP4L7hunsXFyamT]": "1f183cc2e26c4c5d212227ac1ddb17f6c1f712e694cdf4791c707668c1321454",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[abc123ABC_<>-mtHHfh6uHtJiACwp7kzJZ97yueT6sEdQiG]": "12e0eee892de5c479cbe93f0c17e8d81b12dc98453004179dee2660c8452a07e",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[dadadadadadadadadadadadadadadadadadadadadadada-1cc97541": "73166c9bb3e8ce37d0079e723631287f34dd201ee638ce9afe81710cb9c18841",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[dadadadadadadadadadadadadadadadadadadadadadada-ca475dad": "73166c9bb3e8ce37d0079e723631287f34dd201ee638ce9afe81710cb9c18841",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input_over_50_chars": "73166c9bb3e8ce37d0079e723631287f34dd201ee638ce9afe81710cb9c18841",
"T3T1_en_test_passphrase_tt.py::test_passphrase_long_spaces_deletion": "73166c9bb3e8ce37d0079e723631287f34dd201ee638ce9afe81710cb9c18841",
"T3T1_en_test_passphrase_tt.py::test_passphrase_loop_all_characters": "11d4662e7bfbc94e0aedd8ae7f3982641786df721711616292df6858774fbc98",
"T3T1_en_test_passphrase_tt.py::test_passphrase_prompt_disappears": "73166c9bb3e8ce37d0079e723631287f34dd201ee638ce9afe81710cb9c18841",
"T3T1_en_test_backup_slip39_custom.py::test_backup_slip39_custom[1of1]": "b13e22bddd80296bb0d08dff4bc94dad287c40247045eb779b88ba5c53050507",
"T3T1_en_test_backup_slip39_custom.py::test_backup_slip39_custom[2of3]": "ebab6dc1bbf5648693ff34ffece0b67d829d44ac03a751c72dcf623b2cd2c8ba",
"T3T1_en_test_backup_slip39_custom.py::test_backup_slip39_custom[5of5]": "aadf5b948d034af305b275fd0d837619b8f133039b4ed431fa31fd65cc2e143e",
"T3T1_en_test_lock.py::test_hold_to_lock": "9d60d7aa2fbe6a0de14379e02ea825fbf9e21471596498f7be686f2538391f1d",
"T3T1_en_test_passphrase_tt.py::test_cycle_through_last_character": "2a8d54c8014cc0c1bf46c0e4b58d6a002009b62aa8b92db663f88af0ad2f5e19",
"T3T1_en_test_passphrase_tt.py::test_passphrase_click_same_button_many_times": "6a579067b4395a260d173e78643b67ac701304ea833a112cb2da1bce94cbb102",
"T3T1_en_test_passphrase_tt.py::test_passphrase_delete": "6f9fd790c360ea8caa60a183f39d6515ce66493786f71611988f20b6fc5af86d",
"T3T1_en_test_passphrase_tt.py::test_passphrase_delete_all": "386969917a7112629f7a9e3a96f703953d8673a0c9bf5428b7612811566c29e7",
"T3T1_en_test_passphrase_tt.py::test_passphrase_dollar_sign_deletion": "9ec26b92ff4ab6add7216f99329a9b34b59c69dba9ab916a4e9516f0d833b466",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[Y@14lw%p)JN@f54MYvys@zj'g-mnkoxeaMzLgfCxUdDSZW-78765865": "294e640dd8be88a92546107038ff6190e792896ae754d2d3c73e8d8c6bdac8be",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[abc 123-mvqzZUb9NaUc62Buk9WCP4L7hunsXFyamT]": "bd916caf1254ee0fc93febad5f03c603592b4adfbc76c3cfe747ffe54ab6ae54",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[abc123ABC_<>-mtHHfh6uHtJiACwp7kzJZ97yueT6sEdQiG]": "afa0d2c6bdfa50a22983ecbda113074703d21a008dbdd45e11e3d27a3fb704d8",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[dadadadadadadadadadadadadadadadadadadadadadada-1cc97541": "75beea9b4c13023ac3ea12fc7167e10f8d2eea2aa2f82426a0e7129b9bc21880",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input[dadadadadadadadadadadadadadadadadadadadadadada-ca475dad": "c1a1cf0707dec05b41f36aba36d579ec3c93d5c404c565388bfea1787d094e67",
"T3T1_en_test_passphrase_tt.py::test_passphrase_input_over_50_chars": "75beea9b4c13023ac3ea12fc7167e10f8d2eea2aa2f82426a0e7129b9bc21880",
"T3T1_en_test_passphrase_tt.py::test_passphrase_long_spaces_deletion": "dbf02c793dbb0c7e68e6fcfe1b7baeae0abd048c49dfbaf0994146ce46dcdbae",
"T3T1_en_test_passphrase_tt.py::test_passphrase_loop_all_characters": "82ff267d6ec0d48d8a1e25d1e77e598f563449dbff75fca7f2820dc1409fa453",
"T3T1_en_test_passphrase_tt.py::test_passphrase_prompt_disappears": "12a0d2dfe50c122326bd7ab6af7dd32008943091757ef6f5e9122dd721414987",
"T3T1_en_test_pin.py::test_pin_cancel": "05f5f819be61fec8c7c4341fd23c1bccf78cff93f05d573dd4f528bb0f1edbf5",
"T3T1_en_test_pin.py::test_pin_change": "d31775c2f6fb597a9d6f3dda6747b82ab06bd8dbdb401b836c251f0312314548",
"T3T1_en_test_pin.py::test_pin_delete_hold": "5aa2bdc441689642b7c475e368b6c6802805f99be91a302687a39010f1704c08",
@ -8130,6 +8133,9 @@
"TR_en_test_autolock.py::test_dryrun_enter_word_slowly": "f35d159c13b36c428e68969bbeb87fb4bdbfa6c21eb98985b5832849938d6934",
"TR_en_test_autolock.py::test_dryrun_locks_at_number_of_words": "df7a17ab9cd2c617ce22a615e1da9bedee41f978459cbe103f2eecb8dfe8db12",
"TR_en_test_autolock.py::test_dryrun_locks_at_word_entry": "53b39b3ff0548a91e87ac3391462850e4f6060fa1a19ae873eca6fc5cce8dbb2",
"TR_en_test_backup_slip39_custom.py::test_backup_slip39_custom[1of1]": "9e620acfff4ef1442996b81c1fd9c73d20490c2deecc6bc05aa0b665d9cd217c",
"TR_en_test_backup_slip39_custom.py::test_backup_slip39_custom[2of3]": "aa0d1382ef0f2e16e24d0afbd25a105f572cd4f17d91dd44ca6a3f46a60bf6c4",
"TR_en_test_backup_slip39_custom.py::test_backup_slip39_custom[5of5]": "5586a21ef466d85077021c289690f22afe787445523b29900f49ec03468598d8",
"TR_en_test_lock.py::test_hold_to_lock": "83e2d055215b03150069d9fcb3aee6dc3f78a0d2bc43d8133425bf6b000c191d",
"TR_en_test_passphrase_tr.py::test_cancel": "4a79e82b3ddf23c087e70767f2c4d4720f980370e22be209189d4ed42a453ee8",
"TR_en_test_passphrase_tr.py::test_passphrase_delete": "28468562292a54f2d8cc954129c6b1859c267bc5a9b94f9b406352e71a4c8036",
@ -15206,6 +15212,9 @@
"TT_en_test_autolock.py::test_dryrun_enter_word_slowly": "140ff1c01d0d27ade29e88af481a9a24385fbe01058bdbf35f2fa20c19e0c386",
"TT_en_test_autolock.py::test_dryrun_locks_at_number_of_words": "f9a5c8f92ca3b0b9545a9a5b3cf8df4de9943fbe45de113aa6f970d60b3b9b49",
"TT_en_test_autolock.py::test_dryrun_locks_at_word_entry": "2ea54adc6df443f758a6395b6b443fbfe5931cbd62a321504de9ae453aff8ca8",
"TT_en_test_backup_slip39_custom.py::test_backup_slip39_custom[1of1]": "6c33232fdff24175a489c83507bcde9bd859cc2d7f1ca687e154befd0cd2b883",
"TT_en_test_backup_slip39_custom.py::test_backup_slip39_custom[2of3]": "20bdb47f4150af61b2a64927f7df2f49fedf9d02442d103c2833bddcc57f9f93",
"TT_en_test_backup_slip39_custom.py::test_backup_slip39_custom[5of5]": "97fb33be7f98498b0acfcade1e5333475c9e6dce131ca7bdde3a54cf72c595fe",
"TT_en_test_lock.py::test_hold_to_lock": "a5739f92ae28fc57769e444408ce5b58223d0d33b368022ef78ea68e0f8c9b80",
"TT_en_test_passphrase_tt.py::test_cycle_through_last_character": "2a8d54c8014cc0c1bf46c0e4b58d6a002009b62aa8b92db663f88af0ad2f5e19",
"TT_en_test_passphrase_tt.py::test_passphrase_click_same_button_many_times": "6a579067b4395a260d173e78643b67ac701304ea833a112cb2da1bce94cbb102",
@ -19593,11 +19602,14 @@
"TT_en_test_msg_applysettings.py::test_experimental_features": "523f74db7f660c261507dfdd92285981869af72c9ba391c4dfedb3f06ccf40ad",
"TT_en_test_msg_applysettings.py::test_label_too_long": "80a6e289138a604cf351a29511cf6f85e2243591317894703152787e1351a1a3",
"TT_en_test_msg_applysettings.py::test_safety_checks": "b129e0ba2e3c7ac836b3bb4b0bc8ded1c922eba054b42b434d10499fc780ea2b",
"TT_en_test_msg_backup_device.py::test_backup_bip39": "3b90a1c3383b3afa2c321109e1088b2e33d976a9f75db3b013456d52c85ae5d2",
"TT_en_test_msg_backup_device.py::test_backup_slip39_advanced[click_info]": "2738a2e8fb890f7f2f5857cd9af0d3f6eac9fbf71dc299c829b1ba6a1209b712",
"TT_en_test_msg_backup_device.py::test_backup_slip39_advanced[no_click_info]": "503c667f02e099102be7c9bf14e771b5e27f41eabebdf7269389c9d9b33016ae",
"TT_en_test_msg_backup_device.py::test_backup_slip39_basic[click_info]": "65b98eb420a3b4a5128dc686f491a55b460205db7e1e1bb2828a3d6998af5466",
"TT_en_test_msg_backup_device.py::test_backup_slip39_basic[no_click_info]": "a3c26090183f9b150cd0caccfa2a4ba9833de718868777cc1e6f255ddda8a94f",
"TT_en_test_msg_backup_device.py::test_backup_bip39": "09c32059eea15ce17dd07bc34f09ca335e8a6cee573fef0095cf2105932a078a",
"TT_en_test_msg_backup_device.py::test_backup_slip39_advanced[click_info]": "5007dfedc1b4420b096f76d33acc9bcf77ec6083d54ca59571f5d225dfb2a33b",
"TT_en_test_msg_backup_device.py::test_backup_slip39_advanced[no_click_info]": "4f13e0550f8e06481fd4f59d722d3893ea904f85fb20ec8018344347492aff1c",
"TT_en_test_msg_backup_device.py::test_backup_slip39_basic[click_info]": "b432bf3671b15fdcfb92113fcef0e95671c3d7beaae70b25a87d090d30f9a079",
"TT_en_test_msg_backup_device.py::test_backup_slip39_basic[no_click_info]": "4a6f3fdd5d7ae36e6b5ad84faa5385565fd13f4ce3f7d915fa16c2184f99ebbd",
"TT_en_test_msg_backup_device.py::test_backup_slip39_custom[2_of_2]": "53788010ec0f9c81ea07828ae32425c3d38a1d35789aa91d3163e117fa2cbe99",
"TT_en_test_msg_backup_device.py::test_backup_slip39_custom[1_of_1]": "e17b49d05f231444684b0ebad638456b4b6d7b0ae195180778c3f687b7d0ca7d",
"TT_en_test_msg_backup_device.py::test_backup_slip39_custom[3_of_5]": "ebc007c9801856ad27dd6332b7dc5413189191268e10fcf5604203b75d8c52ed",
"TT_en_test_msg_backup_device.py::test_interrupt_backup_fails": "ae147498028d68aa71c7337544e4a5049c4c943897f905c6fe29e88e5c3ab056",
"TT_en_test_msg_backup_device.py::test_no_backup_fails": "fada9d38ec099b3c6a4fd8bf994bb1f3431e40085128b4e0cd9deb8344dec53e",
"TT_en_test_msg_backup_device.py::test_no_backup_show_entropy_fails": "001377ce61dcd189e6a9d17e20dcd71130e951dc3314b40ff26f816bd9355bdd",

Loading…
Cancel
Save