Skip to content

Commit

Permalink
Merge pull request #39 from Coldcard/deventro
Browse files Browse the repository at this point in the history
BIP-85: Derive Entropy (from existing seed)
  • Loading branch information
peter-conalgo authored Jun 9, 2020
2 parents 19c824c + e19c412 commit bd0e8b6
Show file tree
Hide file tree
Showing 12 changed files with 544 additions and 24 deletions.
8 changes: 8 additions & 0 deletions releases/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@

- Enhancement: adds BIP-85 support: "One seed to rule them all". Takes the
secret of this Coldcard, and (safely) constructs a secret/seed phrase you
can import into another wallet system. Supports BIP-39 seeds of 12,18 or 24 words,
"HDSeed" (WIF private key) for Bitcoin Core, a fresh XPRV for BIP-32 systems,
or even 32/64 bytes of hex for other applications. The point of this is your
Coldcard backup also backs up the new wallet, since it's root secret is
deterministically derived. Advanced > DangerZone > Derive Entropy.
- Enhancement: QR Code rendering improved. Should be more readable in more cases. Faster.

## 3.1.3 - April 30, 2020
Expand Down
24 changes: 17 additions & 7 deletions shared/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,16 +562,26 @@ async def view_seed_words(*a):
return

with stash.SensitiveValues() as sv:
assert sv.mode == 'words' # protected by menu item predicate
if sv.mode == 'words':
words = tcc.bip39.from_data(sv.raw).split(' ')

words = tcc.bip39.from_data(sv.raw).split(' ')
msg = 'Seed words (%d):\n' % len(words)
msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words))

msg = 'Seed words (%d):\n' % len(words)
msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words))
pw = stash.bip39_passphrase
if pw:
msg += '\n\nBIP39 Passphrase:\n%s' % stash.bip39_passphrase
elif sv.mode == 'xprv':
import chains
msg = chains.current_chain().serialize_private(sv.node)

pw = stash.bip39_passphrase
if pw:
msg += '\n\nBIP39 Passphrase:\n%s' % stash.bip39_passphrase
elif sv.mode == 'master':
from ubinascii import hexlify as b2a_hex

msg = '%d bytes:\n\n' % len(sv.raw)
msg += str(b2a_hex(sv.raw), 'ascii')
else:
raise ValueError(sv.mode)

await ux_show_story(msg, sensitive=True)

Expand Down
196 changes: 196 additions & 0 deletions shared/drv_entro.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# (c) Copyright 2020 by Coinkite Inc. This file is part of Coldcard <coldcardwallet.com>
# and is covered by GPLv3 license found in COPYING.
#
# BIP-85: Deterministic Entropy From BIP32 Keychains, by
# Ethan Kosakovsky <ethankosakovsky@protonmail.com>
#
# Using the system's BIP32 master key, safely derive seeds phrases/entropy for other
# wallet systems, which may expect seed phrases, XPRV, or other entropy.
#
import stash, tcc, hmac, chains
from ux import ux_show_story, ux_enter_number, the_ux, ux_confirm
from menu import MenuItem, MenuSystem
from ubinascii import hexlify as b2a_hex
from serializations import hash160

def drv_entro_start(*a):

# UX entry
ch = await ux_show_story('''\
Create Entropy for Other Wallets (BIP-85)
This feature derives "entropy" based mathematically on this wallet's seed value. \
This will be displayed as a 12 or 24 word seed phrase, \
or formatted in other ways to make it easy to import into \
other wallet systems.
You can recreate this value later, based \
only the seed-phrase or backup of this Coldcard.
There is no way to reverse the process, should the other wallet system be compromised, \
so the other wallet is effectively segregated from the Coldcard and yet \
still backed-up.''')
if ch != 'y': return

if stash.bip39_passphrase:
if not await ux_confirm('''You have a BIP39 passphrase set right now and so that will become wrapped into the new secret.'''):
return

choices = [ '12 words', '18 words', '24 words', 'WIF (privkey)',
'XPRV (BIP32)', '32-bytes hex', '64-bytes hex']

m = MenuSystem([MenuItem(c, f=drv_entro_step2) for c in choices])
the_ux.push(m)

def drv_entro_step2(_1, picked, _2):
from main import dis
from files import CardSlot, CardMissingError

the_ux.pop()

index = await ux_enter_number("Index Number?", 9999)

if picked in (0,1,2):
# BIP39 seed phrases (we only support English)
num_words = (12, 18, 24)[picked]
width = (16, 24, 32)[picked] # of bytes
path = "m/83696968'/39'/0'/{num_words}'/{index}'".format(num_words=num_words, index=index)
s_mode = 'words'
elif picked == 3:
# HDSeed for Bitcoin Core: but really a WIF of a private key, can be used anywhere
s_mode = 'wif'
path = "m/83696968'/2'/{index}'".format(index=index)
width = 32
elif picked == 4:
# New XPRV
path = "m/83696968'/32'/{index}'".format(index=index)
s_mode = 'xprv'
width = 64
elif picked in (5, 6):
width = 32 if picked == 5 else 64
path = "m/83696968'/128169'/{width}'/{index}'".format(width=width, index=index)
s_mode = 'hex'
else:
raise ValueError(picked)

dis.fullscreen("Working...")
encoded = None

with stash.SensitiveValues() as sv:
node = sv.derive_path(path)
entropy = hmac.HMAC(b'bip-entropy-from-k', node.private_key(), tcc.sha512).digest()
sv.register(entropy)

# truncate for this application
new_secret = entropy[0:width]


# only "new_secret" is interesting past here (node already blanked at this point)
del node

# Reveal to user!
chain = chains.current_chain()

if s_mode == 'words':
# BIP39 seed phrase, various lengths
words = tcc.bip39.from_data(new_secret).split(' ')

msg = 'Seed words (%d):\n' % len(words)
msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words))

encoded = stash.SecretStash.encode(seed_phrase=new_secret)

elif s_mode == 'wif':
# for Bitcoin Core: a 32-byte of secret exponent, base58 w/ prefix 0x80
# - always "compressed", so has suffix of 0x01 (inside base58)
# - we're not checking it's on curve
# - we have no way to represent this internally, since we rely on bip32

# append 0x01 to indicate it's a compressed private key
pk = new_secret + b'\x01'

msg = 'WIF (privkey):\n' + tcc.codecs.b58_encode(chain.b58_privkey + pk)

elif s_mode == 'xprv':
# Raw XPRV value.
ch, pk = new_secret[0:32], new_secret[32:64]
master_node = tcc.bip32.HDNode(chain_code=ch, private_key=pk,
child_num=0, depth=0, fingerprint=0)

encoded = stash.SecretStash.encode(xprv=master_node)

msg = 'Derived XPRV:\n' + chain.serialize_private(master_node)

elif s_mode == 'hex':
# Random hex number for whatever purpose
msg = ('Hex (%d bytes):\n' % width) + str(b2a_hex(new_secret), 'ascii')

stash.blank_object(new_secret)
new_secret = None # no need to print it again
else:
raise ValueError(s_mode)

msg += '\n\nPath Used (index=%d):\n %s' % (index, path)

if new_secret:
msg += '\n\nRaw Entropy:\n' + str(b2a_hex(new_secret), 'ascii')

print(msg) # XXX debug

prompt = '\n\nPress 1 to save to MicroSD card'
if encoded is not None:
prompt += ', 2 to switch to derived secret.'

while 1:
ch = await ux_show_story(msg+prompt, sensitive=True, escape='12')

if ch == '1':
# write to SD card: simple text file
try:
with CardSlot() as card:
fname, out_fn = card.pick_filename('drv-%s-idx%d.txt' % (s_mode, index))

with open(fname, 'wt') as fp:
fp.write(msg)
fp.write('\n')
except CardMissingError:
await needs_microsd()
continue
except Exception as e:
await ux_show_story('Failed to write!\n\n\n'+str(e))
continue

await ux_show_story("Filename is:\n\n%s" % out_fn, title='Saved')
else:
break

if new_secret is not None:
stash.blank_object(new_secret)
stash.blank_object(msg)

if ch == '2' and (encoded is not None):
from main import pa, settings, dis
from pincodes import AE_SECRET_LEN

# switch over to new secret!
dis.fullscreen("Applying...")

stash.bip39_passphrase = ''
tmp_secret = encoded + bytes(AE_SECRET_LEN - len(encoded))

# monkey-patch to block SE access, and just use new secret
pa.fetch = lambda *a, **k: bytearray(tmp_secret)
pa.change = lambda *a, **k: None
pa.ls_fetch = pa.change
pa.ls_change = pa.change

# copies system settings to new encrypted-key value, calculates
# XFP, XPUB and saves into that, and starts using them.
pa.new_main_secret(pa.fetch())

await ux_show_story("New master key in effect until next power down.")

if encoded is not None:
stash.blank_object(encoded)

# EOF
9 changes: 5 additions & 4 deletions shared/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@

if version.has_fatram:
from hsm import hsm_policy_available
from drv_entro import drv_entro_start
else:
hsm_policy_available = lambda: False
drv_entro_start = None

#
# NOTE: "Always In Title Case"
Expand Down Expand Up @@ -131,10 +133,8 @@ def has_secrets():
DangerZoneMenu = [
# xxxxxxxxxxxxxxxx
MenuItem("Debug Functions", menu=DebugFunctionsMenu), # actually harmless
MenuItem('Lock Down Seed', f=convert_bip39_to_bip32,
predicate=lambda: settings.get('words', True)),
MenuItem('View Seed Words', f=view_seed_words,
predicate=lambda: settings.get('words', True)),
MenuItem('Lock Down Seed', f=convert_bip39_to_bip32),
MenuItem('View Seed Words', f=view_seed_words), # text is a little wrong sometimes, rare
MenuItem("Destroy Seed", f=clear_seed),
MenuItem("I Am Developer.", menu=maybe_dev_menu),
MenuItem("Wipe Patch Area", f=wipe_filesystem), # needs better label
Expand All @@ -160,6 +160,7 @@ def has_secrets():
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem("Address Explorer", f=address_explore),
MenuItem('User Management', menu=make_users_menu, predicate=lambda: version.has_fatram),
MenuItem('Derive Entropy', f=drv_entro_start, predicate=lambda: version.has_fatram),
MenuItem("Danger Zone", menu=DangerZoneMenu),
]

Expand Down
5 changes: 0 additions & 5 deletions shared/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,11 +468,6 @@ async def remember_bip39_passphrase():
dis.fullscreen('Check...')

with stash.SensitiveValues() as sv:
if sv.mode != 'words':
# not a BIP39 derived secret, so cannot work.
await ux_show_story('''The wallet secret was not based on a seed phrase, so we cannot add a BIP39 passphrase at this time.''', title='Failed')
return

nv = SecretStash.encode(xprv=sv.node)

# Important: won't write new XFP to nvram if pw still set
Expand Down
10 changes: 6 additions & 4 deletions shared/stash.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,12 @@ def __init__(self, secret=None, bypass_pw=False):
raise ValueError('no secrets yet')

self.secret = pa.fetch()
self.spots = [ self.secret ]
else:
# sometimes we already know it
assert set(secret) != {0}
#assert set(secret) != {0}
self.secret = secret
self.spots = []

# backup during volatile bip39 encryption: do not use passphrase
self._bip39pw = '' if bypass_pw else str(bip39_passphrase)
Expand All @@ -137,9 +139,10 @@ def __enter__(self):

self.mode, self.raw, self.node = SecretStash.decode(self.secret, self._bip39pw)

self.chain = chains.current_chain()
self.spots.append(self.node)
self.spots.append(self.raw)

self.spots = [ self.secret, self.node, self.raw ]
self.chain = chains.current_chain()

return self

Expand All @@ -151,7 +154,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):

if hasattr(self, 'secret'):
# will be blanked from above
assert self.secret == bytes(AE_SECRET_LEN)
del self.secret

if hasattr(self, 'node'):
Expand Down
27 changes: 26 additions & 1 deletion testing/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,32 @@ def doit(prv):
# - actually need seed words for all tests
reset_seed_words()

@pytest.fixture(scope="function")
def set_encoded_secret(sim_exec, sim_execfile, simulator, reset_seed_words):
# load simulator w/ a specific secret

def doit(encoded):
assert 17 <= len(encoded) <= 72

encoded += bytes(72- len(encoded))

sim_exec('import main; main.ENCODED_SECRET = %r; ' % encoded)
rv = sim_execfile('devtest/set_encoded_secret.py')
if rv: pytest.fail(rv)

simulator.start_encryption()
simulator.check_mitm()

#print("sim xfp: 0x%08x" % simulator.master_fingerprint)

return simulator.master_fingerprint

yield doit

# Important cleanup: restore normal key, because other tests assume that
# - actually need seed words for all tests
reset_seed_words()

@pytest.fixture(scope="function")
def set_seed_words(sim_exec, sim_execfile, simulator, reset_seed_words):
# load simulator w/ a specific bip32 master key
Expand Down Expand Up @@ -515,7 +541,6 @@ def doit():




@pytest.fixture()
def settings_set(sim_exec):

Expand Down
19 changes: 19 additions & 0 deletions testing/devtest/set_encoded_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# load up the simulator w/ indicated encoded secret. could be xprv/words/etc.
import tcc, main
from sim_settings import sim_defaults
import stash, chains
from h import b2a_hex
from main import settings, pa
from stash import SecretStash, SensitiveValues
from utils import xfp2str

settings.current = sim_defaults
settings.overrides.clear()

raw = main.ENCODED_SECRET
pa.change(new_secret=raw)
pa.new_main_secret(raw)

print("New key in effect: %s" % settings.get('xpub', 'MISSING'))
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0)))

Loading

0 comments on commit bd0e8b6

Please sign in to comment.