You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

357 lines
13 KiB

# SPDX-FileCopyrightText: 2020 Foundation Devices, Inc. <hello@foundationdevices.com>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# SPDX-FileCopyrightText: 2018 Coinkite, Inc. <coldcardwallet.com>
# SPDX-License-Identifier: GPL-3.0-only
#
# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard <coldcardwallet.com>
# and is covered by GPLv3 license found in COPYING.
#
# settings.py - manage a few key values that aren't super secrets
#
# Goals:
# - Single wallet settings
# - Wear leveling of the flash
# - If no settings are readable, erase flash and start over
#
# Result:
# - up to 4k of values supported (after json encoding)
# - encrypted and stored in SPI flash, in last 128k area
# - AES encryption key is derived from actual wallet secret
# - if logged out, then use fixed key instead (ie. it's public)
# - to support multiple wallets and plausible deniablity, we
# will preserve any noise already there, and only replace our own stuff
# - you cannot move data between slots because AES-CTR with CTR seed based on slot #
# - SHA check on decrypted data
#
import os
import ujson
import ustruct
import uctypes
import gc
import trezorcrypto
from uio import BytesIO
from uasyncio import sleep_ms
from ubinascii import hexlify as b2a_hex
# Base address for internal memory-mapped flash used for settings: 0x81E0000
SETTINGS_FLASH_START = const(0x81E0000)
SETTINGS_FLASH_LENGTH = const(0x20000) # 128K
SETTINGS_FLASH_END = SETTINGS_FLASH_START + SETTINGS_FLASH_LENGTH - 1
DATA_SIZE = const(4096 - 32)
BLOCK_SIZE = const(4096)
# Setting values:
# xfp = master xpub's fingerprint (32 bit unsigned)
# xpub = master xpub in base58
# chain = 3-letter codename for chain we are working on (BTC)
# words = (bool) BIP39 seed words exist (else XPRV or master secret based)
# b39skip = (bool) skip discussion about use of BIP39 passphrase
# idle_to = idle timeout period (seconds)
# _version = internal version number for data - incremented every time the data is saved
# terms_ok = customer has signed-off on the terms of sale
# tested = selftest has been completed successfully
# multisig = list of defined multisig wallets (complex)
# pms = trust/import/distrust xpubs found in PSBT files
# axi = index of last selected address in explorer
# lgto = (minutes) how long to wait for Login Countdown feature
# usr = (dict) map from username to their secret, as base32
# Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug)
# nick = optional nickname for this coldcard (personalization)
# rngk = randomize keypad for PIN entry
# These are the data slots available to use. We have 32 slots
# for flash wear leveling.
SLOT_ADDRS = range(SETTINGS_FLASH_START, SETTINGS_FLASH_END - BLOCK_SIZE, BLOCK_SIZE)
class Settings:
def __init__(self, loop=None):
from foundation import SettingsFlash
self.loop = loop
self.is_dirty = 0
self.aes_key = b'\0' * 32
self.curr_dict = self.default_values()
self.overrides = {} # volatile overide values
self.flash = SettingsFlash()
self.load()
def get_aes(self, flash_offset):
# Build AES key for en/decrypt of specific block.
# Include the slot number as part of the initial counter (CTR)
return trezorcrypto.aes(trezorcrypto.aes.CTR, self.aes_key, ustruct.pack('<4I', 4, 3, 2, flash_offset))
def set_key(self, new_secret=None):
# System settings (not secrets) are stored in internal flash, encrypted with this
# key that is derived from main wallet secret. Call this method when the secret
# is first loaded, or changes for some reason.
from common import pa
from stash import blank_object
key = None
mine = False
if not new_secret:
if not pa.is_successful() or pa.is_secret_blank():
# simple fixed key allows us to store a few things when logged out
key = b'\0'*32
else:
# read secret and use it.
new_secret = pa.fetch()
mine = True
if new_secret:
# hash up the secret... without decoding it or similar
assert len(new_secret) >= 32
s = trezorcrypto.sha256(new_secret)
for round in range(5):
s.update('pad')
s = trezorcrypto.sha256(s.digest())
key = s.digest()
if mine:
blank_object(new_secret)
# for restore from backup case, or when changing (created) the seed
self.aes_key = key
def load(self):
# Search all slots for any we can read, decrypt that,
# and pick the newest one (in unlikely case of dups)
try:
# reset
self.curr_dict.clear()
self.overrides.clear()
self.addr = 0
self.is_dirty = 0
for addr in SLOT_ADDRS:
buf = uctypes.bytearray_at(addr, 4)
if buf[0] == buf[1] == buf[2] == buf[3] == 0xff:
# Save this so we can start at an empty slot when no decodable data
# is found (we can't just start at the beginning since it might
# not be erased).
# print(' Slot is ERASED')
# erased (probably)
continue
# check if first 2 bytes makes sense for JSON
flash_offset = (addr - SETTINGS_FLASH_START) // BLOCK_SIZE
aes = self.get_aes(flash_offset)
chk = aes.decrypt(b'{"')
if chk != buf[0:2]:
# doesn't look like JSON, so skip it
# print(' Slot does not contain JSON')
continue
# probably good, so prepare to read it
aes = self.get_aes(flash_offset)
chk = trezorcrypto.sha256()
expect = None
# Copy the data - our flash is memory mapped, so we read directly by address
buf = uctypes.bytearray_at(addr, DATA_SIZE)
# Get a bytearray for the SHA256 at the end
expected_sha = uctypes.bytearray_at(addr + DATA_SIZE, 32)
# Decrypt and check hash
b = aes.decrypt(buf)
# Add the decrypted result to the SHA
chk.update(b)
try:
# verify hash in last 32 bytes
assert expected_sha == chk.digest()
# FOUNDATION
# loads() can't work from a byte array, and converting to
# bytes here would copy it; better to use file emulation.
# print('json = {}'.format(b))
d = ujson.load(BytesIO(b))
except:
# One in 65k or so chance to come here w/ garbage decoded, so
# not an error.
# print('ERROR? Unable to decode JSON')
continue
curr_version = d.get('_version', 0)
if curr_version > self.curr_dict.get('_version', -1):
# print('Found candidate JSON: {}'.format(d))
# A newer entry was found
self.curr_dict = d
self.addr = addr
# If we loaded settings, then we're done
if self.addr:
return
# Add some che
# if self.
# If no entries were found, which means this is either the first boot or we have corrupt settings, so raise an exception so we erase and set default
# raise ValueError('Flash is either blank or corrupt, so me must reset to recover to avoid a crash!')
self.curr_dict = self.default_values()
self.overrides.clear()
self.addr = 0
except Exception as e:
print('Exception in settings.load(): e={}'.format(e))
self.reset()
self.is_dirty = True
self.write_out()
def get(self, kn, default=None):
if kn in self.overrides:
return self.overrides.get(kn)
else:
return self.curr_dict.get(kn, default)
def changed(self):
self.is_dirty += 1
if self.is_dirty < 2 and self.loop:
self.loop.call_later_ms(250, self.write_out())
def set(self, kn, v):
self.curr_dict[kn] = v
print('Settings: Set {} to {}'.format(kn, v))
self.changed()
def set_volatile(self, kn, v):
self.overrides[kn] = v
def reset(self):
self.erase_settings_flash()
self.curr_dict = self.default_values()
self.overrides.clear()
self.addr = 0
self.is_dirty = False
def erase_settings_flash(self):
self.flash.erase()
async def write_out(self):
# delayed write handler
if not self.is_dirty:
# someone beat me to it
return
# Was sometimes running low on memory in this area: recover
try:
gc.collect()
self.save()
except MemoryError:
# TODO: This would be an infinite async loop if it throws an exception every time -- fix this
self.loop.call_later_ms(250, self.write_out())
def find_first_erased_addr(self):
for addr in SLOT_ADDRS:
buf = uctypes.bytearray_at(addr, 4)
if buf[0] == buf[1] == buf[2] == buf[3] == 0xff:
return addr
return 0
# We use chunks sequentially since there is no benefit to randomness
# here. An attacker needs the PIN to decrypt the AES, and if he has
# the PIN, first of all, it's game over for the Bitcoin, and even if
# the attacker cares about these settings, running AES on each of the
# 32 entries instead of just one is trivial.
def next_addr(self):
# If no entries were found on load, addr will be zero
if self.addr == 0:
addr = self.find_first_erased_addr()
if addr == 0:
# Everything is full, so we must erase and start again
self.flash.erase()
return SETTINGS_FLASH_START
else:
return addr
# Go to next address
if self.addr < SETTINGS_FLASH_END - BLOCK_SIZE:
return self.addr + BLOCK_SIZE
# We reached the end of the bank -- we need to erase it so
# the new settings can be written.
self.flash.erase()
return SETTINGS_FLASH_START
def save(self):
# Render as JSON, encrypt and write it
self.curr_dict['_version'] = self.curr_dict.get('_version', 0) + 1
addr = self.next_addr()
print('===============================================================')
print('SAVING SETTINGS! _version={} addr={}'.format(self.curr_dict['_version'], hex(addr)))
print('===============================================================')
flash_offset = (addr - SETTINGS_FLASH_START) // BLOCK_SIZE
aes = self.get_aes(flash_offset)
chk = trezorcrypto.sha256()
# Create the JSON string as bytes
json_buf = ujson.dumps(self.curr_dict).encode('utf8')
# Ensure data is not too big
# TODO: Check that null byte at the end is handled properly (no overflow)
if len(json_buf) > DATA_SIZE:
# TODO: Proper error handling
assert false, 'JSON data is larger than'.format(DATA_SIZE)
# Create a zero-filled byte buf
padded_buf = bytearray(DATA_SIZE)
# Copy the json data into the padded buffer
for i in range(len(json_buf)):
padded_buf[i] = json_buf[i]
del json_buf
# Add the data and padding to the AES and SHA
encrypted_buf = aes.encrypt(padded_buf)
chk.update(padded_buf)
# Build the final buf for writing to flash
save_buf = bytearray(BLOCK_SIZE)
for i in range(len(encrypted_buf)):
save_buf[i] = encrypted_buf[i] # TODO: How to do this with slice notation so it doesn't truncate destination?
digest = chk.digest()
for i in range(32):
save_buf[BLOCK_SIZE - 32 + i] = digest[i]
# print('addr={}\nbuf={}'.format(hex(addr),b2a_hex(save_buf)))
self.flash.write(addr, save_buf)
# We don't overwrite the old entry here, even though it's now useless, as that can
# cause flash to have ECC errors.
self.addr = addr
self.is_dirty = 0
print("Settings.save(): wrote @ {}".format(hex(addr)))
def merge(self, prev):
# take a dict of previous values and merge them into what we have
self.curr_dict.update(prev)
@staticmethod
def default_values():
# Please try to avoid defaults here. It's better to put into code
# where value is used, and treat undefined as the default state.
return dict(_version=0)
# EOF