Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BIP-85: Derive Entropy (from existing seed) #39

Merged
merged 21 commits into from
Jun 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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