# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard # and is covered by GPLv3 license found in COPYING. # # nvstore.py - manage a few key values that aren't super secrets # # Goals: # - handle multiple wallets in same memory that don't know each other # - some deniability # - recover from empty/blank/failed chips w/o user action # # 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, ujson, tcc, ustruct, ckcc, gc from uasyncio import sleep_ms from uio import BytesIO from sffile import SFFile # 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) # _age = internal verison number for data (see below) # _skip_pin = hard code a PIN value (dangerous, only for debug) # terms_ok = customer has signed-off on the terms of sale # tested = selftest has been completed successfully # # where in SPI Flash we work (last 128k) SLOTS = range((1024-128)*1024, 1024*1024, 4096) # Altho seems bad to statically alloc this big block, it solves # concerns with heap fragmentation, and saving settings is clearly # core to our mission! # 4k, but last 32 bytes are a SHA (itself encrypted) #_tmp = bytearray(4096-32) from sram2 import nvstore_buf _tmp = nvstore_buf class SettingsObject: def __init__(self, loop=None): self.loop = loop self.is_dirty = 0 self.my_pos = 0 self.nvram_key = b'\0'*32 self.current = self.default_values() self.overrides = {} # volatile overide values self.load() def get_aes(self, mode, pos): # Build AES key for en/decrypt of specific block. # Include the slot number as part of the initial counter (CTR) return tcc.AES(tcc.AES.CTR | mode, self.nvram_key, ustruct.pack('<4I', 4, 3, 2, pos)) def set_key(self, new_secret=None): # System settings (not secrets) are stored in SPI 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 main 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 = tcc.sha256(new_secret) for round in range(5): s.update('pad') s = tcc.sha256(s.digest()) key = s.digest() if mine: blank_object(new_secret) # for restore from backup case, or when changing (created) the seed self.nvram_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) from main import sf # reset self.current.clear() self.overrides.clear() self.my_pos = 0 self.is_dirty = 0 # 4k, but last 32 bytes are a SHA (itself encrypted) global _tmp buf = bytearray(4) empty = 0 for pos in SLOTS: gc.collect() sf.read(pos, buf) if buf[0] == buf[1] == buf[2] == buf[3] == 0xff: # erased (probably) empty += 1 continue # check if first 2 bytes makes sense for JSON aes = self.get_aes(tcc.AES.Encrypt, pos) chk = aes.update(b'{"') if chk != buf[0:2]: # doesn't look like JSON meant for me continue # probably good, read it aes = self.get_aes(tcc.AES.Encrypt, pos) chk = tcc.sha256() expect = None with SFFile(pos, length=4096, pre_erased=True) as fd: for i in range(4096/32): b = aes.update(fd.read(32)) if i != 127: _tmp[i*32:(i*32)+32] = b chk.update(b) else: expect = b try: # verify checksum in last 32 bytes assert expect == chk.digest() # loads() can't work from a byte array, and converting to # bytes here would copy it; better to use file emulation. d = ujson.load(BytesIO(_tmp)) except: # One in 65k or so chance to come here w/ garbage decoded, so # not an error. continue got_age = d.get('_age', 0) if got_age > self.current.get('_age', -1): # likely winner self.current = d self.my_pos = pos #print("NV: data @ %d w/ age=%d" % (pos, got_age)) else: # stale data seen; clean it up. assert self.current['_age'] > 0 print("NV: cleanup @ %d" % pos) sf.sector_erase(pos) sf.wait_done() # 4k is a large object, sigh, for us right now. cleanup gc.collect() # done, if we found something if self.my_pos: return # nothing found. self.my_pos = 0 self.current = self.default_values() if empty == len(SLOTS): # Whole thing is blank. Bad for plausible deniability. Write 3 slots # with garbage. They will be wasted space until it fills. blks = list(SLOTS) tcc.random.shuffle(blks) for pos in blks[0:3]: for i in range(0, 4096, 256): h = tcc.random.bytes(256) sf.wait_done() sf.write(pos+i, h) def get(self, kn, default=None): if kn in self.overrides: return self.overrides.get(kn) else: return self.current.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 put(self, kn, v): self.current[kn] = v self.changed() def put_volatile(self, kn, v): self.overrides[kn] = v set = put def clear(self): # could be just: # self.current = {} # but accomidating the simulator here rk = [k for k in self.current if k[0] != '_'] for k in rk: del self.current[k] self.overrides.clear() self.changed() 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() #print("settings committed") except MemoryError: print("write_out retry") self.loop.call_later_ms(250, self.write_out()) def find_spot(self, not_here=0): # search for a blank sector to use # - check randomly and pick first blank one (wear leveling, deniability) # - we will write and then erase old slot # - if "full", blow away a random one from main import sf options = [s for s in SLOTS if s != not_here] tcc.random.shuffle(options) buf = bytearray(16) for pos in options: sf.read(pos, buf) if set(buf) == {0xff}: # blank return sf, pos # No where to write! (probably a bug because we have lots of slots) # ... so pick a random slot and kill what it had #print("ERROR: nvram full?") victem = options[0] sf.sector_erase(victem) sf.wait_done() return sf, victem def save(self): # render as JSON, encrypt and write it. self.current['_age'] = self.current.get('_age', 1) + 1 sf, pos = self.find_spot(self.my_pos) aes = self.get_aes(tcc.AES.Encrypt, pos) with SFFile(pos, max_size=4096, pre_erased=True) as fd: chk = tcc.sha256() # first the json data d = ujson.dumps(self.current) fd.write(aes.update(d)) chk.update(d) # pad w/ zeros pad_len = (4096-32) - len(d) del d assert pad_len >= 0, 'too big' while pad_len > 0: here = min(32, pad_len) pad = bytes(here) fd.write(aes.update(pad)) chk.update(pad) pad_len -= here fd.write(aes.update(chk.digest())) assert fd.tell() == 4096 # erase old copy of data if self.my_pos and self.my_pos != pos: sf.wait_done() sf.sector_erase(self.my_pos) sf.wait_done() self.my_pos = pos self.is_dirty = 0 #print("NV: wrote @ %d" % pos) def merge(self, prev): # take a dict of previous values and merge them into what we have self.current.update(prev) def blank(self): # erase current copy of values in nvram; older ones may exist still # - use when clearing the seed value from main import sf if self.my_pos: sf.wait_done() sf.sector_erase(self.my_pos) self.my_pos = 0 # act blank too, just in case. self.current.clear() self.overrides.clear() self.is_dirty = 0 @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. if ckcc.is_simulator(): from sim_settings import sim_defaults return dict(sim_defaults) return dict(_age=0) # EOF