From f309058a1689ae3e359c36e4d154827c211fb989 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:22:34 -0500 Subject: [PATCH 01/31] Create LICENSE --- reference/python-codex32/LICENSE | 121 +++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 reference/python-codex32/LICENSE diff --git a/reference/python-codex32/LICENSE b/reference/python-codex32/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/reference/python-codex32/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. From 3d35f64845285aa667c22c36487ba17b6fd15508 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:23:48 -0500 Subject: [PATCH 02/31] Create Cargo.toml --- reference/python-codex32/Cargo.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 reference/python-codex32/Cargo.toml diff --git a/reference/python-codex32/Cargo.toml b/reference/python-codex32/Cargo.toml new file mode 100644 index 0000000..0d52b9a --- /dev/null +++ b/reference/python-codex32/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "codex32" +version = "0.1.0" +edition = "2018" +description = "Python3 reference implementation of the codex32 spec" +license = "CC0-1.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] From d4a0a8afd7f13ede9c501ba084332e568fbe2eb8 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:24:35 -0500 Subject: [PATCH 03/31] Create lib.py --- reference/python-codex32/src/lib.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 reference/python-codex32/src/lib.py diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/reference/python-codex32/src/lib.py @@ -0,0 +1 @@ + From fc8aa558d05f14259d077feb378d009ce33d70dc Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:25:14 -0500 Subject: [PATCH 04/31] Create gf32.py --- reference/python-codex32/src/gf32.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 reference/python-codex32/src/gf32.py diff --git a/reference/python-codex32/src/gf32.py b/reference/python-codex32/src/gf32.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/reference/python-codex32/src/gf32.py @@ -0,0 +1 @@ + From 2b709c36a61fabe5d3e9f877fff46d5d5892fff0 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:25:38 -0500 Subject: [PATCH 05/31] Create checksum.py --- reference/python-codex32/src/checksum.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 reference/python-codex32/src/checksum.py diff --git a/reference/python-codex32/src/checksum.py b/reference/python-codex32/src/checksum.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/reference/python-codex32/src/checksum.py @@ -0,0 +1 @@ + From e0cdc33daaaa42f8b8bed16ac853867a685a5f12 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Wed, 9 Aug 2023 10:41:56 -0500 Subject: [PATCH 06/31] Update lib.py Pseudo code outline of major early generation stages --- reference/python-codex32/src/lib.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 8b13789..f7c2736 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -1 +1,29 @@ +CHARSET = '' +import hashlib +def generate_master_seed(user_entropy = '', app_entropy, seed_len): + print(app_entropy) # unconditional display + master_seed = hashlib.scrypt(password = bytes(user_entropy), salt = app_entropy, r = 8, dk_len = seed_len) + return master_seed + +def generate_id(master_seed): + fingerprint = bip32.fingerprint(master_seed) + return bech32.encode(fingerprint)[:4] + +def generate_secret(hrp = 'ms', k = '0', id = '', master_seed): + if not id: + id = generate_id(master_seed) + codex32_secret = encode(hrp, k, id, "s", master_seed) + return codex32_secret + +def generate_shares(codex32_string_list = [], start = 0, stop, step=1): + indexes_available = CHARSET + for codex32_string in codex32_string_list: + hrp, k, identifier, share_index, data = decode(string) + indexes_available = indexes_available.remove(share_index) + if share_index == "s": + master_seed = data + + for i in range(start, stop, step): + new_shares += [share[i]] + return new_shares \ No newline at end of file From d73498a3683b13cc28792659c9a4cb3f01d98522 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Fri, 11 Aug 2023 14:17:57 -0500 Subject: [PATCH 07/31] Update lib.py --- reference/python-codex32/src/lib.py | 476 ++++++++++++++++++++++++++-- 1 file changed, 449 insertions(+), 27 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index f7c2736..736f1d4 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -1,29 +1,451 @@ -CHARSET = '' +#!/bin/python3 +# Author: Leon Olsson Curr and Pearlwort Sneed +# License: BSD-3-Clause + import hashlib -def generate_master_seed(user_entropy = '', app_entropy, seed_len): - print(app_entropy) # unconditional display - master_seed = hashlib.scrypt(password = bytes(user_entropy), salt = app_entropy, r = 8, dk_len = seed_len) - return master_seed - -def generate_id(master_seed): - fingerprint = bip32.fingerprint(master_seed) - return bech32.encode(fingerprint)[:4] - -def generate_secret(hrp = 'ms', k = '0', id = '', master_seed): - if not id: - id = generate_id(master_seed) - codex32_secret = encode(hrp, k, id, "s", master_seed) - return codex32_secret - -def generate_shares(codex32_string_list = [], start = 0, stop, step=1): - indexes_available = CHARSET - for codex32_string in codex32_string_list: - hrp, k, identifier, share_index, data = decode(string) - indexes_available = indexes_available.remove(share_index) - if share_index == "s": - master_seed = data - - for i in range(start, stop, step): - new_shares += [share[i]] - return new_shares \ No newline at end of file +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +MS32_CONST = 0x10ce0795c2fd1e62a +MS32_LONG_CONST = 0x43381e570bf4798ab26 +bech32_inv = [ + 0, 1, 20, 24, 10, 8, 12, 29, 5, 11, 4, 9, 6, 28, 26, 31, + 22, 18, 17, 23, 2, 25, 16, 19, 3, 21, 14, 30, 13, 7, 27, 15, +] + + +def ms32_polymod(values): + GEN = [ + 0x19dc500ce73fde210, + 0x1bfae00def77fe529, + 0x1fbd920fffe7bee52, + 0x1739640bdeee3fdad, + 0x07729a039cfc75f5a, + ] + residue = 0x23181b3 + for v in values: + b = (residue >> 60) + residue = (residue & 0x0fffffffffffffff) << 5 ^ v + for i in range(5): + residue ^= GEN[i] if ((b >> i) & 1) else 0 + return residue + + +def ms32_verify_checksum(data): + if len(data) >= 96: # See Long codex32 Strings + return ms32_verify_long_checksum(data) + if len(data) <= 93: + return ms32_polymod(data) == MS32_CONST + return False + + +def ms32_create_checksum(data): + if len(data) > 80: # See Long codex32 Strings + return ms32_create_long_checksum(data) + values = data + polymod = ms32_polymod(values + [0] * 13) ^ MS32_CONST + return [(polymod >> 5 * (12 - i)) & 31 for i in range(13)] + + +def ms32_long_polymod(values): + GEN = [ + 0x3d59d273535ea62d897, + 0x7a9becb6361c6c51507, + 0x543f9b7e6c38d8a2a0e, + 0x0c577eaeccf1990d13c, + 0x1887f74f8dc71b10651, + ] + residue = 0x23181b3 + for v in values: + b = (residue >> 70) + residue = (residue & 0x3fffffffffffffffff) << 5 ^ v + for i in range(5): + residue ^= GEN[i] if ((b >> i) & 1) else 0 + return residue + + +def ms32_verify_long_checksum(data): + return ms32_long_polymod(data) == MS32_LONG_CONST + + +def ms32_create_long_checksum(data): + values = data + polymod = ms32_long_polymod(values + [0] * 15) ^ MS32_LONG_CONST + return [(polymod >> 5 * (14 - i)) & 31 for i in range(15)] + + +def bech32_mul(a, b): + res = 0 + for i in range(5): + res ^= a if ((b >> i) & 1) else 0 + a *= 2 + a ^= 41 if (32 <= a) else 0 + return res + + +def bech32_lagrange(l, x): + n = 1 + c = [] + for i in l: + n = bech32_mul(n, i ^ x) + m = 1 + for j in l: + m = bech32_mul(m, (x if i == j else i) ^ j) + c.append(m) + return [bech32_mul(n, bech32_inv[i]) for i in c] + + +def ms32_interpolate(l, x): + w = bech32_lagrange([s[5] for s in l], x) + res = [] + for i in range(len(l[0])): + n = 0 + for j in range(len(l)): + n ^= bech32_mul(w[j], l[j][i]) + res.append(n) + return res + + +def ms32_recover(l): + return ms32_interpolate(l, 16) + + +# Copyright (c) 2023 Ben Westgate +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +def ms32_encode(hrp, data): + """Compute a MS32 string given HRP and data values.""" + combined = data + ms32_create_checksum(data) + return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + + +def ms32_decode(bech): + """Validate a MS32 string, and determine HRP and data.""" + if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or + (bech.lower() != bech and bech.upper() != bech)): + return (None, None, None, None, None) + bech = bech.lower() + pos = bech.rfind('1') + if pos < 1 or pos + 46 > len(bech): + return (None, None, None, None, None) + if not all(x in CHARSET for x in bech[pos + 1:]): + return (None, None, None, None, None) + hrp = bech[:pos] + k = bech[pos + 1] + if k == "1" or not k.isdigit(): + return (None, None, None, None, None) + identifier = bech[pos + 2:pos + 6] + share_index = bech[pos + 6] + if k == "0" and share_index != "s": + return (None, None, None, None, None) + data = [CHARSET.find(x) for x in bech[pos + 1:]] + checksum_length = 13 if len(data) < 95 else 15 + if not ms32_verify_checksum(data): + return (None, None, None, None, None) + return (hrp, k, identifier, share_index, data[:-checksum_length]) + + +def convertbits(data, frombits, tobits, pad=True): + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits: + return None + return ret + + +def decode(hrp, codex_str): + """Decode a codex32 string.""" + hrpgot, k, identifier, share_index, data = ms32_decode(codex_str) + if hrpgot != hrp: + return (None, None, None, None) + decoded = convertbits(data[6:], 5, 8, False) + if decoded is None or len(decoded) < 16 or len(decoded) > 64: + return (None, None, None, None) + if k == "1": + return (None, None, None, None) + return (k, identifier, share_index, decoded) + + +def encode(hrp, k, identifier, share_index, payload): + """Encode a codex32 string""" + if share_index.lower() == 's': # add double sha256 hash byte to pad seeds + checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest() + else: + checksum = b'' # TODO: use a reed solomon or bch binary ECCcode for padding. + data = convertbits(payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] + ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in k + identifier + share_index] + data) + if decode(hrp, ret) == (None, None, None, None): + return None + return ret + + +def recover_master_seed(share_list = []): + """Recover a master seed from a threshold of valid codex32 shares.""" + ms32_share_list = [ms32_decode(share)[4] for share in share_list] + return bytes(convertbits(ms32_recover(ms32_share_list)[6:],5,8, False)) + + +def derive_additional_share(codex32_string_list = [], fresh_share_index = "s"): + """Derive additional share at distinct new index from a threshold of valid codex32 strings.""" + ms32_share_list = [ms32_decode(string)[4] for string in codex32_string_list] + ms32_share_index = CHARSET.find(fresh_share_index.lower()) + return ms32_encode('ms', ms32_interpolate(ms32_share_list, ms32_share_index)) + +def fingerprint_identifier(master_seed): + from cryptography.hazmat.primitives import hashes, hmac + from cryptography.hazmat.primitives.asymmetric.ec import derive_private_key, SECP256K1 + SECP256K1 + + i = hmac.HMAC(b'Bitcoin seed', hashes.SHA512()).update(master_seed).finalize() + master_secret_key = int(i[:32]) + private_masterkey_data = derive_private_key(master_secret_key, SECP256K1) + public_master_key_data = private_masterkey_data.public_key() + + + hashlib.new('ripemd') + +def relabel_shares(hrp, share_list, new_identifier): + """Change the identifier on a list of shares.""" + new_share_list = [] + for share in share_list: + k, identifier, share_index, decoded = decode(hrp, share) + new_share_list += [encode(hrp, k, new_identifier, share_index, decoded)] + return new_share_list + + +def fresh_master_seed(bitcoin_core_entropy, user_entropy = '', seed_length = 16, k = '2', identifier = ''): + """Derive a fresh master seed of seed length bytes with optional user-provided entropy.""" + import hmac + import hashlib + share_list = [] + share_payload = [] + if 16 > seed_length > 64: + return None + if 9 < k < 2: + return None + available_indices = list(CHARSET) + letters = sorted(filter(lambda c: c.isalpha(), CHARSET)) + key = hashlib.scrypt(password=bytes(user_entropy + str(seed_length+k+identifier), "utf"), + salt=bytes(bitcoin_core_entropy, "utf"), n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=64) + if not identifier: + identifier = 'temp' # place-holder until strings can be relabeled with better info + for i in range(int(k)): + # generate random initial shares + share_index = letters[i] + available_indices.remove(share_index) + header = 'ms1'+ k + identifier + share_index + info = str(seed_length)+'-byte share with header: '+header + share_payload[i] = hmac.new(key, info, hashlib.sha512).digest()[:seed_length] + share_list += [encode('ms1', k, identifier, share_index, share_payload[i])] + + master_seed = recover_master_seed(share_list) + if identifier == 'temp': + salt = b''.join(share_payload) + identifier_data = hashlib.pbkdf2_hmac('sha512', password = master_seed, salt = salt, + iterations = 2048, dklen = 3) + new_identifier = ''.join([CHARSET[d] for d in convertbits(identifier_data, 8, 5)[:4]]) + share_list = relabel_shares('ms1', share_list, new_identifier) + return master_seed, share_list + + + + +def generate_shares(hrp, share_list, n, k = '2', identifier = '',reshare = False): + """Derive n new set of n shares deterministically from provided share list. Resets identifier.""" + for i, codex_string in enumerate(share_list): + k[i], identifier[i], share_index[i], decoded[i] = decode(hrp, codex_string) + decode('ms',) + +def generate_shares_from_existing_master_seed(hrp, share_list, n, k = '2', identifier = '',reshare = False): + """Derive n new set of n shares deterministically from provided share list. Resets identifier.""" + for i, codex_string in enumerate(share_list): + k[i], identifier[i], share_index[i], decoded[i] = decode(hrp, codex_string) + decode('ms',) + + + +def seed_identifier(master_seed): + """Derive identifier from master seed hash to distinguish between multiple different master seeds.""" + identifier = '' + h = hashlib.new('ripemd160') + h.update(master_seed) + hd_seed_id = h.digest() + for char in convertbits(list(hd_seed_id), 8, 5)[:4]: + identifier += CHARSET[char] + return identifier # allows connecting different share sets to the same master seed + + +def existing_master_seed(master_seed, k, identifier, n, passphrase): + """Derive codex32 shares from a master_seed, threshold k, identifier, n and passphrase.""" + import random + import hmac + derived_share_list = [] + if int(k) - 1 > n > 30 or 9 < int(k) < 2: + return (None) + seed_length = len(master_seed) + codex32_secret = encode("ms", seed_length, k, identifier, "s", list(master_seed)) + codex32_kdf_share, salt, indices_free = kdf_share(passphrase, k, identifier, seed_length) + random.seed(a=hmac.digest(master_seed, salt, 'sha512')) + share_index = random.sample(indices_free, n) + share_list = [codex32_secret, codex32_kdf_share] + if int(k) > 2: + share_entropy = hashlib.scrypt(password=master_seed, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, + dklen=(int(k) - 2) * (seed_length + 1)) + for x in range(int(k) - 2): + data = list(share_entropy[x * (seed_length + 1):x * (seed_length + 1) + seed_length + 1]) + share_list += [encode("ms", seed_length, k, identifier, share_index[x], data)] + for x in range(int(k) - 2, n): + derived_share_list += [derive_new_share(share_list, share_index[x])] + paper_shares = share_list[2:] + derived_share_list + for share in paper_shares: + print(share) + return share_list + derived_share_list + + +def kdf_share(passphrase, hrp, k, identifier, seed_length): + """Derive codex32 share from a passphrase and header+length salt.""" + import random + salt = bytes(hrp + k + identifier + str(seed_length), "utf") + pw_hash = hashlib.scrypt(password=bytes(passphrase, "utf"), salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, + dklen=seed_length) + indices_free = CHARSET.replace('s', '') + random.seed(a=salt) + kdf_share_index = random.choice(indices_free) + indices_free = indices_free.replace(kdf_share_index, '') + codex32_kdf_share = encode("ms", seed_length, k, identifier, kdf_share_index, list(pw_hash)) + return (codex32_kdf_share, salt, indices_free) + + +def recover_master_seed(share_list=[]): + """Derive codex32 secret from a threshold of shares.""" + k = [None] * len(share_list) + identifier = [None] * len(share_list) + share_index = [None] * len(share_list) + decoded = [None] * len(share_list) + for i in range(len(share_list)): + k[i], identifier[i], share_index[i], decoded[i] = decode("ms", share_list[i]) + if k.count(k[0]) != len(k) or identifier.count(identifier[0]) != len(identifier): + return (None) + if len({len(i) for i in decoded}) != 1: + return (None) + return recover(share_list, 's') + + +def identifier_verify_checksum(codex32_secret): + """Verify an identifier checksum in a codex32 secret.""" + k, identifier, share_index, decoded = decode("ms", codex32_secret) + hash_id = seed_identifier(bytes(decoded)) + if share_index != 's' or hash_id[:3] != identifier[:3]: + print('1') + return False + print('0') + return True + + +def verify_checksum(codex32_string): + """Verify a codex32 checksum in a codex32 string.""" + k, identifier, share_index, decoded = decode("ms", codex32_string) + if decoded == None or len(decoded) < 16: + print('1') + return False + print('0') + return True + + +def rotate_shares(codex32_secret, k, n, passphrase=''): + """Rotate codex32 backup shares after a lost or stolen share or passphrase or to change k.""" + old_k, identifier, index, master_seed = decode('ms', codex32_secret) + if index != 's': + return (None) + hrp, old_k, old_id, ms32_index, ms32_decoded = ms32_decode(codex32_secret) + ms32_decoded[4] = (ms32_decoded[4] + 1) % 32 + new_id = ms32_encode('ms', ms32_decoded)[4:8] + return existing_master_seed(bytes(master_seed), k, new_id, n, passphrase) + + +# import secrets + +# master_seed = fresh_master_seed(16,'Walletasdfpassword34','L5EZftvrYaSudiozVRzTqLcHLNDoVn7H5HSfM9BAN6tMJX8oTWz6') +master_seed = b"\x8a\xa0'm\xad\xa1\xf9\tY\xc5\x86r\xfd\x96\x1bY" +id = 'cash' +print(id) +codex_secret = encode("ms", "0", id, 's', master_seed) +print(codex_secret) +# backup = existing_master_seed(master_seed,'3',id,4,'password') +# new_backup = rotate_shares('ms13uccps32szwmdd58usjkw9see0m9smtydt6h374kxafvw','2',2,'password1') +# print(backup) +# print(new_backup) +# new_backup = existing_master_seed(new_master_seed,'2','6666',4,'password') +# print(new_backup) +# print(recover_master_seed(new_backup[2:4])) +# print(recover_master_seed(new_backup[3:5])) +# print(recover_master_seed(new_backup[4:])) +# print(recover_master_seed([new_backup[3]],'password')) +# print(recover_master_seed([new_backup[5]],'password')) +# codex_secret = recover_master_seed([new_backup[5]],'password') +# print(verify_identifier_checksum(codex_secret)) + +# new_backup = existing_master_seed(new_master_seed,'2','0g0d',4,'password') +# print(new_backup) +# codex_secret = recover_master_seed([new_backup[5]],'password') +# print(verify_identifier_checksum(codex_secret)) + + +# test vector 1 +# test_vec1 = ['MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM','MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN'] +# print(recover_master_seed(test_vec1)) +# new_secret = recover_master_seed([test_vec1[0]],'a strong password') + + +# ['ms126gpdszx8y0uuwrqtxfkdvxecqzm52ulrhkkhsn2d6704', 'ms126gpd2uen8lyvrl38wyfp6x6ae7rrd7ff09gp694a74mu', 'ms126gpdxru7qp4j9a4mpzva2xa5jujferem7uh3g4srdakf', 'ms126gpd5s9m79nkv6mcrt47mxrl7m5jxhgdcxyq7yf8vpnp', 'ms126gpd07yg0rks42jwqj5gkxj95t3szf9sa32drfgpenqd', 'ms126gpdadad38s5ududzmdt8xvwcvhtaa5xmteu4c39c099'] +# test vectors +# seed = "dc5423251cb87175ff8110c8531d0952d8d73e1194e95b5f19d6f9df7c01111104c9baecdfea8cccc677fb9ddc8aec5553b86e528bcadfdcc201c17c638c47e9" +# seed_bytes = list(bytes.fromhex(seed)) +# print(encode("ms","0","0C8V","S",seed_bytes)) +# print(decode("ms","MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK")) +# print(encode("ms","0","0c8v","s",decode("ms","MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK")[3])) +# seed = decode("ms","MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK") +# print(encode("ms", '0', 'leet', 's', seed[3])) +# print(seed_bytes) +# print(encode("ms", seed_bytes)) +# print(decode("ms","ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw")[3]) +# print(encode("ms","0","test", "s", seed_bytes)) +# ms_string = encode("ms","0","test","s",list(bytes.fromhex("318c6318c6318c6318c6318c6318c631"))) +# print(ms_string) +# print(decode("ms","ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln")) +# print(decode("ms","ms13cashsllhdmn9m42vcsamx24zrxgs3qpte35dvzkjpt0r")) +# print(ms32_encode("ms",derive_new_share(["MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM","MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN"],"D"))) + +# print(ms32_encode("ms",derive_new_share(["ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln","ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t","ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr"],"f"))) From cd17d453528aae6406b674282c19a6f63cb04434 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 15 Aug 2023 01:02:47 -0500 Subject: [PATCH 08/31] Update lib.py --- reference/python-codex32/src/lib.py | 330 +++++++++++++++++----------- 1 file changed, 200 insertions(+), 130 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 736f1d4..ee5a50f 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -3,6 +3,9 @@ # License: BSD-3-Clause import hashlib +import hmac +# ChaCha20 used for shuffle keystream and encrypting identifier +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" MS32_CONST = 0x10ce0795c2fd1e62a @@ -151,7 +154,7 @@ def ms32_decode(bech): k = bech[pos + 1] if k == "1" or not k.isdigit(): return (None, None, None, None, None) - identifier = bech[pos + 2:pos + 6] + ident = bech[pos + 2:pos + 6] share_index = bech[pos + 6] if k == "0" and share_index != "s": return (None, None, None, None, None) @@ -159,7 +162,7 @@ def ms32_decode(bech): checksum_length = 13 if len(data) < 95 else 15 if not ms32_verify_checksum(data): return (None, None, None, None, None) - return (hrp, k, identifier, share_index, data[:-checksum_length]) + return (hrp, k, ident, share_index, data[:-checksum_length]) def convertbits(data, frombits, tobits, pad=True): @@ -187,7 +190,7 @@ def convertbits(data, frombits, tobits, pad=True): def decode(hrp, codex_str): """Decode a codex32 string.""" - hrpgot, k, identifier, share_index, data = ms32_decode(codex_str) + hrpgot, k, ident, share_index, data = ms32_decode(codex_str) if hrpgot != hrp: return (None, None, None, None) decoded = convertbits(data[6:], 5, 8, False) @@ -195,17 +198,17 @@ def decode(hrp, codex_str): return (None, None, None, None) if k == "1": return (None, None, None, None) - return (k, identifier, share_index, decoded) + return (k, ident, share_index, decoded) -def encode(hrp, k, identifier, share_index, payload): +def encode(hrp, k, ident, share_index, payload): """Encode a codex32 string""" if share_index.lower() == 's': # add double sha256 hash byte to pad seeds checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest() else: checksum = b'' # TODO: use a reed solomon or bch binary ECCcode for padding. data = convertbits(payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] - ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in k + identifier + share_index] + data) + ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in k + ident + share_index] + data) if decode(hrp, ret) == (None, None, None, None): return None return ret @@ -217,157 +220,235 @@ def recover_master_seed(share_list = []): return bytes(convertbits(ms32_recover(ms32_share_list)[6:],5,8, False)) +### BEGINNING OF BACKUP CREATION IMPLEMENTATION ### + + def derive_additional_share(codex32_string_list = [], fresh_share_index = "s"): """Derive additional share at distinct new index from a threshold of valid codex32 strings.""" ms32_share_list = [ms32_decode(string)[4] for string in codex32_string_list] ms32_share_index = CHARSET.find(fresh_share_index.lower()) return ms32_encode('ms', ms32_interpolate(ms32_share_list, ms32_share_index)) -def fingerprint_identifier(master_seed): - from cryptography.hazmat.primitives import hashes, hmac - from cryptography.hazmat.primitives.asymmetric.ec import derive_private_key, SECP256K1 - SECP256K1 - i = hmac.HMAC(b'Bitcoin seed', hashes.SHA512()).update(master_seed).finalize() - master_secret_key = int(i[:32]) - private_masterkey_data = derive_private_key(master_secret_key, SECP256K1) - public_master_key_data = private_masterkey_data.public_key() +def fingerprint_ident(payload_list): + from electrum.bip32 import BIP32Node + from electrum.crypto import hash_160 + pubkeys = b'' + for data in payload_list: + root_node = BIP32Node.from_rootseed(data, xtype="stanadrd") + pubkeys += root_node.eckey.get_public_key_bytes(compressed=True) + return ''.join([CHARSET[d] for d in convertbits(hash_160(pubkeys), 8,5)]) - hashlib.new('ripemd') +def fingerprint(seed): + """Get the bip32 fingerprint of a seed in bech32.""" + from electrum.bip32 import BIP32Node + fingerprint = BIP32Node.from_rootseed(seed, xtype="stanadrd").calc_fingerprint_of_this_node() + return ''.join([CHARSET[d] for d in convertbits(fingerprint, 8,5)])[:4] -def relabel_shares(hrp, share_list, new_identifier): - """Change the identifier on a list of shares.""" + +def relabel_shares(hrp, share_list, new_ident): + """Change the ident on a list of shares.""" new_share_list = [] for share in share_list: - k, identifier, share_index, decoded = decode(hrp, share) - new_share_list += [encode(hrp, k, new_identifier, share_index, decoded)] + k, ident, share_index, decoded = decode(hrp, share) + new_share_list += [encode(hrp, k, new_ident, share_index, decoded)] return new_share_list -def fresh_master_seed(bitcoin_core_entropy, user_entropy = '', seed_length = 16, k = '2', identifier = ''): +def fresh_master_seed(bitcoin_core_entropy, user_entropy = '', seed_length = 16, k = '2', ident = '', n = 31): """Derive a fresh master seed of seed length bytes with optional user-provided entropy.""" - import hmac - import hashlib - share_list = [] - share_payload = [] + # implementations must unconditionally display "App Entropy" for auditing if 16 > seed_length > 64: return None - if 9 < k < 2: + master_seed = hashlib.scrypt(password=bytes(user_entropy + str(seed_length) + k + ident, "utf"), + salt=bytes(bitcoin_core_entropy, "utf"), n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=seed_length) + return existing_master_seed(master_seed, k, n, ident, False) + + +def existing_master_seed(master_seed, k, n, ident = '', reshare = True): + """Derive n new set of n shares deterministically from master seed.""" + if k == "1" or not k.isdigit(): + return None + if int(k) > n: + return None + if not ident: + ident = fingerprint(master_seed) + codex32_secret = encode('ms', k, n, ident, 's', master_seed) + if k == '0': + return [codex32_secret] * n + return existing_codex32_secret(codex32_secret, new_k = k, n = n, reshare=reshare) + + +def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): + """Shuffle indices deterministically using provided entropy uses: HMAC-SHA256. + + Args: + index_seed (bytes): The seed used for deterministic shuffling. + indexes (str): Characters to be shuffled (default: CHARSET without 's'). + + Returns: + list: Shuffled characters sorted based on assigned values. + """ + counter = 0 # Counter to track the current position in the keystream + digest = b'' # Storage for HMAC digest + value = b'' # Storage for the assigned random value + assigned_values = {} # Dictionary to store characters and their values + for char in indexes: + # Ensure a new random value is generated whenever there is a collision + while value in assigned_values.values() or not value: + if not counter % 32: # Generate a new digest every 32 bytes + digest = hmac.new(index_seed, (counter // 32).to_bytes(8, "big"), hashlib.sha256).digest() + value = digest[counter % 32 : counter % 32 + 1] # assign 1 random byte per count + counter += 1 + assigned_values[char] = value + return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) + +def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): + """Shuffle indices deterministically using provided entropy uses: ChaCha20. + + Args: + index_seed (bytes): The seed used for deterministic shuffling. + indexes (str): Characters to be shuffled (default: CHARSET without 's'). + + Returns: + list: Shuffled characters sorted based on assigned values. + """ + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms + algorithm = algorithms.ChaCha20(index_seed, bytes(16)) + keystream = Cipher(algorithm, mode=None).encryptor() + counter = 0 # Counter to track the current position in the keystream + value = b'' # Storage for the assigned random value + assigned_values = {} # Dictionary to store characters and their values + for char in indexes: + # Ensure a new random value is generated whenever there is a collision + while value in assigned_values.values() or not value: + if not counter % 64: # Generate a new 64-byte block every 64 bytes + block = keystream.update(bytes(64)) # Storage for ChaCha20 block + value = block[counter % 64 : counter % 64 + 1] # assign 1 random byte per count + counter += 1 + assigned_values[char] = value + return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) + + +def existing_codex32_secret(codex32_secret, n = 31, forgot = False): + """Derive a fresh set of n shares deterministically from a codex32 secret + This implementation uses the birthdate nonce if forgot = True + """ + import datetime + codex32_string_list = [codex32_secret] + shuffled_share_list = [] + k, ident, share_index, master_seed = decode('ms', codex32_secret) + seed_length = len(master_seed) + payload_list = [master_seed] + # date used to create a unique nonce if user forgets any past identifiers + date = datetime.date.today().strftime("%Y%m%d") if forgot else '19700101' + fingerprint = fingerprint_ident([master_seed]) + salt = bytes(codex32_secret[:9] + fingerprint + date, 'utf') + derived_key = hashlib.pbkdf2_hmac('sha512', password=master_seed, salt=salt, + iterations=2048, dklen=64) + index_seed = hmac.digest(derived_key, b'Index seed', hashlib.sha256) + shuffled_indices = shuffle_indices(index_seed, CHARSET.replace(share_index, '')) + for i in range(int(k - 1)): + info = bytes('Share ' + CHARSET[i], 'utf') + payload_list += [hmac.digest(derived_key, info, hashlib.sha512())[:seed_length]] + codex32_string_list += [encode('ms', k, ident, CHARSET[i], payload_list[i + 1])] + if forgot: + new_ident = fingerprint_ident(payload_list)[:4] + codex32_string_list = relabel_shares('ms', codex32_string_list, new_ident) + for j in range(n): + shuffled_share_list += [derive_additional_share(codex32_string_list, shuffled_indices[j])] + return shuffled_share_list + +def existing_codex32_secret(codex32_secret, new_k = '', new_ident = '', n = 31, reshare = True): + """Derive a fresh set of n shares deterministically from master seed. + This implementation encrypts the identifier of the provided codex32 secret + with the birthdate if reshare = True. Allows changing k. + """ + import datetime + shuffled_share_list = [] + k, ident, share_index, master_seed = decode('ms', codex32_secret) + k = new_k if new_k != k else k + if int(new_k) > n: return None - available_indices = list(CHARSET) - letters = sorted(filter(lambda c: c.isalpha(), CHARSET)) - key = hashlib.scrypt(password=bytes(user_entropy + str(seed_length+k+identifier), "utf"), - salt=bytes(bitcoin_core_entropy, "utf"), n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=64) - if not identifier: - identifier = 'temp' # place-holder until strings can be relabeled with better info - for i in range(int(k)): - # generate random initial shares - share_index = letters[i] - available_indices.remove(share_index) - header = 'ms1'+ k + identifier + share_index - info = str(seed_length)+'-byte share with header: '+header - share_payload[i] = hmac.new(key, info, hashlib.sha512).digest()[:seed_length] - share_list += [encode('ms1', k, identifier, share_index, share_payload[i])] - - master_seed = recover_master_seed(share_list) - if identifier == 'temp': - salt = b''.join(share_payload) - identifier_data = hashlib.pbkdf2_hmac('sha512', password = master_seed, salt = salt, - iterations = 2048, dklen = 3) - new_identifier = ''.join([CHARSET[d] for d in convertbits(identifier_data, 8, 5)[:4]]) - share_list = relabel_shares('ms1', share_list, new_identifier) - return master_seed, share_list - - - - -def generate_shares(hrp, share_list, n, k = '2', identifier = '',reshare = False): - """Derive n new set of n shares deterministically from provided share list. Resets identifier.""" - for i, codex_string in enumerate(share_list): - k[i], identifier[i], share_index[i], decoded[i] = decode(hrp, codex_string) - decode('ms',) - -def generate_shares_from_existing_master_seed(hrp, share_list, n, k = '2', identifier = '',reshare = False): - """Derive n new set of n shares deterministically from provided share list. Resets identifier.""" - for i, codex_string in enumerate(share_list): - k[i], identifier[i], share_index[i], decoded[i] = decode(hrp, codex_string) - decode('ms',) - - - -def seed_identifier(master_seed): - """Derive identifier from master seed hash to distinguish between multiple different master seeds.""" - identifier = '' - h = hashlib.new('ripemd160') - h.update(master_seed) - hd_seed_id = h.digest() - for char in convertbits(list(hd_seed_id), 8, 5)[:4]: - identifier += CHARSET[char] - return identifier # allows connecting different share sets to the same master seed - - -def existing_master_seed(master_seed, k, identifier, n, passphrase): - """Derive codex32 shares from a master_seed, threshold k, identifier, n and passphrase.""" - import random - import hmac - derived_share_list = [] - if int(k) - 1 > n > 30 or 9 < int(k) < 2: - return (None) seed_length = len(master_seed) - codex32_secret = encode("ms", seed_length, k, identifier, "s", list(master_seed)) - codex32_kdf_share, salt, indices_free = kdf_share(passphrase, k, identifier, seed_length) - random.seed(a=hmac.digest(master_seed, salt, 'sha512')) - share_index = random.sample(indices_free, n) - share_list = [codex32_secret, codex32_kdf_share] - if int(k) > 2: - share_entropy = hashlib.scrypt(password=master_seed, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, - dklen=(int(k) - 2) * (seed_length + 1)) - for x in range(int(k) - 2): - data = list(share_entropy[x * (seed_length + 1):x * (seed_length + 1) + seed_length + 1]) - share_list += [encode("ms", seed_length, k, identifier, share_index[x], data)] - for x in range(int(k) - 2, n): - derived_share_list += [derive_new_share(share_list, share_index[x])] - paper_shares = share_list[2:] + derived_share_list - for share in paper_shares: - print(share) - return share_list + derived_share_list - - -def kdf_share(passphrase, hrp, k, identifier, seed_length): - """Derive codex32 share from a passphrase and header+length salt.""" + # date used to create a unique nonce & identifier for reshares + date = datetime.date.today().strftime("%Y%m%d") if reshare else '19700101' + fingerprint = fingerprint_ident([master_seed]) # gets full hash160(pub_masterkey) + salt = bytes(codex32_secret[:9] + fingerprint + date, 'utf') # using old codex32 secret header in salt for reshare ident! + if reshare or new_k or new_ident: + if not new_ident: + # encrypt the old ident by the date + new_ident = encrypt_ident(ident, date, salt) + ident = new_ident + codex32_secret = encode('ms', k, ident, share_index, master_seed) + salt = bytes(codex32_secret[:9] + fingerprint + date, 'utf') # use the new header for derived key + derived_key = hashlib.pbkdf2_hmac('sha512', password=master_seed, salt=salt, + iterations=2048, dklen=64) + codex32_string_list = [codex32_secret] + for i in range(int(k - 1)): + info = bytes('Share ' + CHARSET[i], 'utf') + payload = hmac.digest(derived_key, info, hashlib.sha512)[:seed_length] + codex32_string_list += [encode('ms', k, ident, CHARSET[i], payload)] + index_seed = hmac.digest(derived_key, b'Index seed', hashlib.sha256) + shuffled_indices = shuffle_indices(index_seed, CHARSET.replace(share_index, '')) + for j in range(n): + shuffled_share_list += [derive_additional_share(codex32_string_list, shuffled_indices[j])] + return shuffled_share_list + + +def encrypt_ident(ident, date, salt): + new_ident_bytes = b'' + encryption_key = hashlib.pbkdf2_hmac('sha512', password=date, salt=salt, + iterations=2048, dklen=32) + encryptor = Cipher(algorithms.ChaCha20(encryption_key, bytes(16)), mode=None).encryptor() + ident_bytes = convertbits([CHARSET.find(x) for x in ident], 5, 8, False) + + while new_ident_bytes == ident_bytes or not new_ident_bytes: + new_ident_bytes = encryptor.update(ident_bytes) + return ''.join([CHARSET[d] for d in convertbits(new_ident_bytes, 8, 5)])[:4] + + +### END OF REFERENCE IMPLEMENTATION ### + + + +def kdf_share(passphrase, codex32_share): + """Derive codex32 share from a passphrase and the header of another share.""" import random - salt = bytes(hrp + k + identifier + str(seed_length), "utf") + salt = bytes(codex32_share[:8]), "utf") + seed_len = len(decode('ms1', codex32_share)[3]) pw_hash = hashlib.scrypt(password=bytes(passphrase, "utf"), salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=seed_length) - indices_free = CHARSET.replace('s', '') - random.seed(a=salt) + passphrase_index_seed = hmac.digest(pw_hash, 'Passphrase Share Index Seed') + shuffle_indices(passphrase_index_seed ,CHARSET.replace('s', '')) + indices_free = kdf_share_index = random.choice(indices_free) indices_free = indices_free.replace(kdf_share_index, '') - codex32_kdf_share = encode("ms", seed_length, k, identifier, kdf_share_index, list(pw_hash)) + codex32_kdf_share = encode("ms", seed_length, k, ident, kdf_share_index, list(pw_hash)) return (codex32_kdf_share, salt, indices_free) def recover_master_seed(share_list=[]): """Derive codex32 secret from a threshold of shares.""" k = [None] * len(share_list) - identifier = [None] * len(share_list) + ident = [None] * len(share_list) share_index = [None] * len(share_list) decoded = [None] * len(share_list) for i in range(len(share_list)): - k[i], identifier[i], share_index[i], decoded[i] = decode("ms", share_list[i]) - if k.count(k[0]) != len(k) or identifier.count(identifier[0]) != len(identifier): + k[i], ident[i], share_index[i], decoded[i] = decode("ms", share_list[i]) + if k.count(k[0]) != len(k) or ident.count(ident[0]) != len(ident): return (None) if len({len(i) for i in decoded}) != 1: return (None) return recover(share_list, 's') -def identifier_verify_checksum(codex32_secret): +def ident_verify_checksum(codex32_secret): """Verify an identifier checksum in a codex32 secret.""" - k, identifier, share_index, decoded = decode("ms", codex32_secret) - hash_id = seed_identifier(bytes(decoded)) - if share_index != 's' or hash_id[:3] != identifier[:3]: + k, ident, share_index, decoded = decode("ms", codex32_secret) + fp_id = fingerprint(bytes(decoded)) + if share_index != 's' or fp_id[:4] != ident[:4]: print('1') return False print('0') @@ -376,7 +457,7 @@ def identifier_verify_checksum(codex32_secret): def verify_checksum(codex32_string): """Verify a codex32 checksum in a codex32 string.""" - k, identifier, share_index, decoded = decode("ms", codex32_string) + k, ident, share_index, decoded = decode("ms", codex32_string) if decoded == None or len(decoded) < 16: print('1') return False @@ -384,17 +465,6 @@ def verify_checksum(codex32_string): return True -def rotate_shares(codex32_secret, k, n, passphrase=''): - """Rotate codex32 backup shares after a lost or stolen share or passphrase or to change k.""" - old_k, identifier, index, master_seed = decode('ms', codex32_secret) - if index != 's': - return (None) - hrp, old_k, old_id, ms32_index, ms32_decoded = ms32_decode(codex32_secret) - ms32_decoded[4] = (ms32_decoded[4] + 1) % 32 - new_id = ms32_encode('ms', ms32_decoded)[4:8] - return existing_master_seed(bytes(master_seed), k, new_id, n, passphrase) - - # import secrets # master_seed = fresh_master_seed(16,'Walletasdfpassword34','L5EZftvrYaSudiozVRzTqLcHLNDoVn7H5HSfM9BAN6tMJX8oTWz6') @@ -415,12 +485,12 @@ def rotate_shares(codex32_secret, k, n, passphrase=''): # print(recover_master_seed([new_backup[3]],'password')) # print(recover_master_seed([new_backup[5]],'password')) # codex_secret = recover_master_seed([new_backup[5]],'password') -# print(verify_identifier_checksum(codex_secret)) +# print(verify_ident_checksum(codex_secret)) # new_backup = existing_master_seed(new_master_seed,'2','0g0d',4,'password') # print(new_backup) # codex_secret = recover_master_seed([new_backup[5]],'password') -# print(verify_identifier_checksum(codex_secret)) +# print(verify_ident_checksum(codex_secret)) # test vector 1 From a88a023bd559cb12615c21f3e8d6f639ed0ce4c2 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 15 Aug 2023 01:49:39 -0500 Subject: [PATCH 09/31] Delete gf32.py has no function --- reference/python-codex32/src/gf32.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 reference/python-codex32/src/gf32.py diff --git a/reference/python-codex32/src/gf32.py b/reference/python-codex32/src/gf32.py deleted file mode 100644 index 8b13789..0000000 --- a/reference/python-codex32/src/gf32.py +++ /dev/null @@ -1 +0,0 @@ - From 8f8bce42ea0af16f005fc6ef3a29370cfafaaebc Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Tue, 15 Aug 2023 01:49:57 -0500 Subject: [PATCH 10/31] Delete checksum.py --- reference/python-codex32/src/checksum.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 reference/python-codex32/src/checksum.py diff --git a/reference/python-codex32/src/checksum.py b/reference/python-codex32/src/checksum.py deleted file mode 100644 index 8b13789..0000000 --- a/reference/python-codex32/src/checksum.py +++ /dev/null @@ -1 +0,0 @@ - From da938643454cded3da2075a8fbd5982f6e1d4c09 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Fri, 18 Aug 2023 15:52:41 -0500 Subject: [PATCH 11/31] 'unique string' replaces date. Runs now. Update lib.py --- reference/python-codex32/src/lib.py | 215 ++++++++-------------------- 1 file changed, 59 insertions(+), 156 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index ee5a50f..7be3a50 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -4,7 +4,7 @@ import hashlib import hmac -# ChaCha20 used for shuffle keystream and encrypting identifier +# ChaCha20 used for encrypting ident and a better keystream option for shuffle from cryptography.hazmat.primitives.ciphers import Cipher, algorithms CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" @@ -198,7 +198,10 @@ def decode(hrp, codex_str): return (None, None, None, None) if k == "1": return (None, None, None, None) - return (k, ident, share_index, decoded) + return k, ident, share_index, bytes(decoded) + + +### Beginning of Codex32 Generation Implementation ### def encode(hrp, k, ident, share_index, payload): @@ -206,7 +209,7 @@ def encode(hrp, k, ident, share_index, payload): if share_index.lower() == 's': # add double sha256 hash byte to pad seeds checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest() else: - checksum = b'' # TODO: use a reed solomon or bch binary ECCcode for padding. + checksum = b'0x00' # TODO: use a reed solomon or bch binary ECCcode for padding. data = convertbits(payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in k + ident + share_index] + data) if decode(hrp, ret) == (None, None, None, None): @@ -220,9 +223,6 @@ def recover_master_seed(share_list = []): return bytes(convertbits(ms32_recover(ms32_share_list)[6:],5,8, False)) -### BEGINNING OF BACKUP CREATION IMPLEMENTATION ### - - def derive_additional_share(codex32_string_list = [], fresh_share_index = "s"): """Derive additional share at distinct new index from a threshold of valid codex32 strings.""" ms32_share_list = [ms32_decode(string)[4] for string in codex32_string_list] @@ -230,21 +230,29 @@ def derive_additional_share(codex32_string_list = [], fresh_share_index = "s"): return ms32_encode('ms', ms32_interpolate(ms32_share_list, ms32_share_index)) -def fingerprint_ident(payload_list): +def masterkey_identifier(master_seed): + """Get the 20 byte key identifier of the master pubkey""" from electrum.bip32 import BIP32Node from electrum.crypto import hash_160 - pubkeys = b'' - for data in payload_list: - root_node = BIP32Node.from_rootseed(data, xtype="stanadrd") - pubkeys += root_node.eckey.get_public_key_bytes(compressed=True) - return ''.join([CHARSET[d] for d in convertbits(hash_160(pubkeys), 8,5)]) - + root_node = BIP32Node.from_rootseed(master_seed, xtype="stanadrd") + return hash_160(root_node.eckey.get_public_key_bytes(compressed=True)) def fingerprint(seed): - """Get the bip32 fingerprint of a seed in bech32.""" + """Get the 4 byte bip32 fingerprint of a seed.""" from electrum.bip32 import BIP32Node - fingerprint = BIP32Node.from_rootseed(seed, xtype="stanadrd").calc_fingerprint_of_this_node() - return ''.join([CHARSET[d] for d in convertbits(fingerprint, 8,5)])[:4] + bip32_fingerprint = BIP32Node.from_rootseed(seed, xtype="stanadrd").calc_fingerprint_of_this_node() + print(bip32_fingerprint) + bech32_fingerprint = ''.join([CHARSET[d] for d in convertbits(bip32_fingerprint, 8,5)])[:4] + return bip32_fingerprint, bech32_fingerprint + + +def encrypt_ident(master_seed, k, unique_string): + salt = len(master_seed).to_bytes(1, 'big') + bytes('ms1' + k, 'utf') + encryption_key = hashlib.pbkdf2_hmac('sha512', password=bytes(unique_string, 'utf'), + salt=salt, iterations=2048, dklen=4) + ident_bytes = fingerprint(master_seed)[0] + new_ident_bytes = bytes([x ^ y for x, y in zip(ident_bytes, encryption_key)]) + return ''.join([CHARSET[d] for d in convertbits(new_ident_bytes, 8, 5)])[:4] def relabel_shares(hrp, share_list, new_ident): @@ -266,18 +274,18 @@ def fresh_master_seed(bitcoin_core_entropy, user_entropy = '', seed_length = 16, return existing_master_seed(master_seed, k, n, ident, False) -def existing_master_seed(master_seed, k, n, ident = '', reshare = True): +def existing_master_seed(master_seed, k, n, ident = '', unique_string = ''): """Derive n new set of n shares deterministically from master seed.""" if k == "1" or not k.isdigit(): return None if int(k) > n: return None if not ident: - ident = fingerprint(master_seed) - codex32_secret = encode('ms', k, n, ident, 's', master_seed) + ident = encrypt_ident(master_seed, k, unique_string) + codex32_secret = encode('ms', k, ident, 's', master_seed) if k == '0': return [codex32_secret] * n - return existing_codex32_secret(codex32_secret, new_k = k, n = n, reshare=reshare) + return existing_codex32_secret(codex32_secret, new_k = k, n = n, unique_string = unique_string) def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): @@ -304,6 +312,7 @@ def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): assigned_values[char] = value return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) + def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): """Shuffle indices deterministically using provided entropy uses: ChaCha20. @@ -331,62 +340,28 @@ def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) -def existing_codex32_secret(codex32_secret, n = 31, forgot = False): - """Derive a fresh set of n shares deterministically from a codex32 secret - This implementation uses the birthdate nonce if forgot = True - """ - import datetime - codex32_string_list = [codex32_secret] - shuffled_share_list = [] - k, ident, share_index, master_seed = decode('ms', codex32_secret) - seed_length = len(master_seed) - payload_list = [master_seed] - # date used to create a unique nonce if user forgets any past identifiers - date = datetime.date.today().strftime("%Y%m%d") if forgot else '19700101' - fingerprint = fingerprint_ident([master_seed]) - salt = bytes(codex32_secret[:9] + fingerprint + date, 'utf') - derived_key = hashlib.pbkdf2_hmac('sha512', password=master_seed, salt=salt, - iterations=2048, dklen=64) - index_seed = hmac.digest(derived_key, b'Index seed', hashlib.sha256) - shuffled_indices = shuffle_indices(index_seed, CHARSET.replace(share_index, '')) - for i in range(int(k - 1)): - info = bytes('Share ' + CHARSET[i], 'utf') - payload_list += [hmac.digest(derived_key, info, hashlib.sha512())[:seed_length]] - codex32_string_list += [encode('ms', k, ident, CHARSET[i], payload_list[i + 1])] - if forgot: - new_ident = fingerprint_ident(payload_list)[:4] - codex32_string_list = relabel_shares('ms', codex32_string_list, new_ident) - for j in range(n): - shuffled_share_list += [derive_additional_share(codex32_string_list, shuffled_indices[j])] - return shuffled_share_list - -def existing_codex32_secret(codex32_secret, new_k = '', new_ident = '', n = 31, reshare = True): +def existing_codex32_secret(codex32_secret, new_k = '', new_ident = '', n = 31, unique_string = ''): """Derive a fresh set of n shares deterministically from master seed. - This implementation encrypts the identifier of the provided codex32 secret - with the birthdate if reshare = True. Allows changing k. - """ - import datetime + This implementation encrypts the fingerprint identifier of the provided + codex32 secret with a unique string. Allows changing k.""" shuffled_share_list = [] k, ident, share_index, master_seed = decode('ms', codex32_secret) k = new_k if new_k != k else k - if int(new_k) > n: - return None seed_length = len(master_seed) - # date used to create a unique nonce & identifier for reshares - date = datetime.date.today().strftime("%Y%m%d") if reshare else '19700101' - fingerprint = fingerprint_ident([master_seed]) # gets full hash160(pub_masterkey) - salt = bytes(codex32_secret[:9] + fingerprint + date, 'utf') # using old codex32 secret header in salt for reshare ident! - if reshare or new_k or new_ident: + header = codex32_secret[:9] + key_identifier = masterkey_identifier(master_seed) # gets full hash160(pub_masterkey) + salt = bytes(header + unique_string, 'utf') + key_identifier # using old codex32 secret header in salt for reshare ident! + if unique_string or new_k or new_ident: if not new_ident: # encrypt the old ident by the date - new_ident = encrypt_ident(ident, date, salt) + new_ident = encrypt_ident(master_seed, k, unique_string) ident = new_ident codex32_secret = encode('ms', k, ident, share_index, master_seed) - salt = bytes(codex32_secret[:9] + fingerprint + date, 'utf') # use the new header for derived key + salt = bytes(codex32_secret[:9] + unique_string, 'utf') + key_identifier # use the new header for derived key derived_key = hashlib.pbkdf2_hmac('sha512', password=master_seed, salt=salt, iterations=2048, dklen=64) codex32_string_list = [codex32_secret] - for i in range(int(k - 1)): + for i in range(int(k) - 1): info = bytes('Share ' + CHARSET[i], 'utf') payload = hmac.digest(derived_key, info, hashlib.sha512)[:seed_length] codex32_string_list += [encode('ms', k, ident, CHARSET[i], payload)] @@ -397,36 +372,30 @@ def existing_codex32_secret(codex32_secret, new_k = '', new_ident = '', n = 31, return shuffled_share_list -def encrypt_ident(ident, date, salt): - new_ident_bytes = b'' - encryption_key = hashlib.pbkdf2_hmac('sha512', password=date, salt=salt, - iterations=2048, dklen=32) - encryptor = Cipher(algorithms.ChaCha20(encryption_key, bytes(16)), mode=None).encryptor() - ident_bytes = convertbits([CHARSET.find(x) for x in ident], 5, 8, False) - - while new_ident_bytes == ident_bytes or not new_ident_bytes: - new_ident_bytes = encryptor.update(ident_bytes) - return ''.join([CHARSET[d] for d in convertbits(new_ident_bytes, 8, 5)])[:4] - - -### END OF REFERENCE IMPLEMENTATION ### +def decrypt_ident(codex32_string, unique_string = ''): + """Returns bech32 fingerprint if unique string corresponds to the share and share is correct.""" + k, ident, share_index, payload = decode('ms', codex32_string) + salt = len(payload).to_bytes(1, 'big') + bytes('ms1' + k, 'utf') + encryption_key = hashlib.pbkdf2_hmac('sha512', password=bytes(unique_string, 'utf'), + salt=salt, iterations=2048, dklen=4) + ciphertext = bytes(convertbits([CHARSET.find(x) for x in ident], 5, 8, True)) + plaintext = bytes([x ^ y for x, y in zip(ciphertext, encryption_key)]) + return ''.join([CHARSET[d] for d in convertbits(plaintext, 8,5)])[:4] def kdf_share(passphrase, codex32_share): """Derive codex32 share from a passphrase and the header of another share.""" import random - salt = bytes(codex32_share[:8]), "utf") - seed_len = len(decode('ms1', codex32_share)[3]) + salt = bytes(codex32_share[:8], "utf") + seed_length = len(decode('ms1', codex32_share)[3]) pw_hash = hashlib.scrypt(password=bytes(passphrase, "utf"), salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=seed_length) passphrase_index_seed = hmac.digest(pw_hash, 'Passphrase Share Index Seed') - shuffle_indices(passphrase_index_seed ,CHARSET.replace('s', '')) - indices_free = - kdf_share_index = random.choice(indices_free) - indices_free = indices_free.replace(kdf_share_index, '') + shuffled_indices = shuffle_indices(passphrase_index_seed ,CHARSET.replace('s', ''))[0] + indices_free = shuffled_indices[1:] codex32_kdf_share = encode("ms", seed_length, k, ident, kdf_share_index, list(pw_hash)) - return (codex32_kdf_share, salt, indices_free) + return (codex32_kdf_share, indices_free, salt) def recover_master_seed(share_list=[]): @@ -444,78 +413,12 @@ def recover_master_seed(share_list=[]): return recover(share_list, 's') -def ident_verify_checksum(codex32_secret): - """Verify an identifier checksum in a codex32 secret.""" - k, ident, share_index, decoded = decode("ms", codex32_secret) - fp_id = fingerprint(bytes(decoded)) - if share_index != 's' or fp_id[:4] != ident[:4]: - print('1') - return False - print('0') - return True - - -def verify_checksum(codex32_string): - """Verify a codex32 checksum in a codex32 string.""" - k, ident, share_index, decoded = decode("ms", codex32_string) - if decoded == None or len(decoded) < 16: - print('1') - return False - print('0') - return True +# master_seed = fresh_master_seed(16,'Walletasdfpassword34','L5EZftvrYaSudiozVRzTqLcHLNDoVn7H5HSfM9BAN6tMJX8oTWz6') +seed = bytes(16) +share_list = existing_master_seed(seed, k = '2', n=2, ident = '', unique_string = 'a') +print(share_list) +print(len(share_list[0])) -# import secrets +print(decrypt_ident('ms12mm2y8v7yuccjj2e0rvs8av2jlef3rvhmkem347ue40qr', 'a')) -# master_seed = fresh_master_seed(16,'Walletasdfpassword34','L5EZftvrYaSudiozVRzTqLcHLNDoVn7H5HSfM9BAN6tMJX8oTWz6') -master_seed = b"\x8a\xa0'm\xad\xa1\xf9\tY\xc5\x86r\xfd\x96\x1bY" -id = 'cash' -print(id) -codex_secret = encode("ms", "0", id, 's', master_seed) -print(codex_secret) -# backup = existing_master_seed(master_seed,'3',id,4,'password') -# new_backup = rotate_shares('ms13uccps32szwmdd58usjkw9see0m9smtydt6h374kxafvw','2',2,'password1') -# print(backup) -# print(new_backup) -# new_backup = existing_master_seed(new_master_seed,'2','6666',4,'password') -# print(new_backup) -# print(recover_master_seed(new_backup[2:4])) -# print(recover_master_seed(new_backup[3:5])) -# print(recover_master_seed(new_backup[4:])) -# print(recover_master_seed([new_backup[3]],'password')) -# print(recover_master_seed([new_backup[5]],'password')) -# codex_secret = recover_master_seed([new_backup[5]],'password') -# print(verify_ident_checksum(codex_secret)) - -# new_backup = existing_master_seed(new_master_seed,'2','0g0d',4,'password') -# print(new_backup) -# codex_secret = recover_master_seed([new_backup[5]],'password') -# print(verify_ident_checksum(codex_secret)) - - -# test vector 1 -# test_vec1 = ['MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM','MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN'] -# print(recover_master_seed(test_vec1)) -# new_secret = recover_master_seed([test_vec1[0]],'a strong password') - - -# ['ms126gpdszx8y0uuwrqtxfkdvxecqzm52ulrhkkhsn2d6704', 'ms126gpd2uen8lyvrl38wyfp6x6ae7rrd7ff09gp694a74mu', 'ms126gpdxru7qp4j9a4mpzva2xa5jujferem7uh3g4srdakf', 'ms126gpd5s9m79nkv6mcrt47mxrl7m5jxhgdcxyq7yf8vpnp', 'ms126gpd07yg0rks42jwqj5gkxj95t3szf9sa32drfgpenqd', 'ms126gpdadad38s5ududzmdt8xvwcvhtaa5xmteu4c39c099'] -# test vectors -# seed = "dc5423251cb87175ff8110c8531d0952d8d73e1194e95b5f19d6f9df7c01111104c9baecdfea8cccc677fb9ddc8aec5553b86e528bcadfdcc201c17c638c47e9" -# seed_bytes = list(bytes.fromhex(seed)) -# print(encode("ms","0","0C8V","S",seed_bytes)) -# print(decode("ms","MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK")) -# print(encode("ms","0","0c8v","s",decode("ms","MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK")[3])) -# seed = decode("ms","MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK") -# print(encode("ms", '0', 'leet', 's', seed[3])) -# print(seed_bytes) -# print(encode("ms", seed_bytes)) -# print(decode("ms","ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw")[3]) -# print(encode("ms","0","test", "s", seed_bytes)) -# ms_string = encode("ms","0","test","s",list(bytes.fromhex("318c6318c6318c6318c6318c6318c631"))) -# print(ms_string) -# print(decode("ms","ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln")) -# print(decode("ms","ms13cashsllhdmn9m42vcsamx24zrxgs3qpte35dvzkjpt0r")) -# print(ms32_encode("ms",derive_new_share(["MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM","MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN"],"D"))) - -# print(ms32_encode("ms",derive_new_share(["ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln","ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t","ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr"],"f"))) From d8ae4d7e5cef30755209085cb15a7b313644aa72 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Fri, 25 Aug 2023 03:18:58 -0500 Subject: [PATCH 12/31] Near final draft, Update lib.py should check off all tasklists. Generate shares gets entropy from a provided master xprv, or derives that master xprv from a provided codex32 secret in existing string list. Shuffle uses chacha20 and 1 byte samples. Generate shares always produces shares first. Default identifier is bip32 fingerprint in bech32. PEP8 Docstrings added for all my functions --- reference/python-codex32/src/lib.py | 526 ++++++++++++++++++---------- 1 file changed, 340 insertions(+), 186 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 7be3a50..09f3125 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -2,10 +2,14 @@ # Author: Leon Olsson Curr and Pearlwort Sneed # License: BSD-3-Clause -import hashlib +from hashlib import scrypt, pbkdf2_hmac, sha256, sha512 import hmac +from electrum.bip32 import BIP32Node +from electrum.crypto import hash_160 + # ChaCha20 used for encrypting ident and a better keystream option for shuffle from cryptography.hazmat.primitives.ciphers import Cipher, algorithms +from cryptography.hazmat.primitives.hashes import SHA512_256 CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" MS32_CONST = 0x10ce0795c2fd1e62a @@ -134,31 +138,43 @@ def ms32_recover(l): def ms32_encode(hrp, data): - """Compute a MS32 string given HRP and data values.""" + """ + Compute an MS32 string. + + :param hrp: Human-readable part of the ms32 string, usually 'ms'. + :param data: List of base32 integers representing data to encode. + :return: MS32 encoded string with the given HRP and data. + """ combined = data + ms32_create_checksum(data) return hrp + '1' + ''.join([CHARSET[d] for d in combined]) -def ms32_decode(bech): - """Validate a MS32 string, and determine HRP and data.""" - if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or - (bech.lower() != bech and bech.upper() != bech)): +def ms32_decode(ms32_str): + """ + Validate an MS32 string and extract components. + + :param ms32_str: The MS32 encoded string to be validated. + :return: Tuple: HRP, k, ident, share index, data. If invalid, + (None, None, None, None, None) + """ + if ((any(ord(x) < 33 or ord(x) > 126 for x in ms32_str)) or + (ms32_str.lower() != ms32_str and ms32_str.upper() != ms32_str)): return (None, None, None, None, None) - bech = bech.lower() - pos = bech.rfind('1') - if pos < 1 or pos + 46 > len(bech): + ms32_str = ms32_str.lower() + pos = ms32_str.rfind('1') + if pos < 1 or pos + 46 > len(ms32_str): return (None, None, None, None, None) - if not all(x in CHARSET for x in bech[pos + 1:]): + if not all(x in CHARSET for x in ms32_str[pos + 1:]): return (None, None, None, None, None) - hrp = bech[:pos] - k = bech[pos + 1] + hrp = ms32_str[:pos] + k = ms32_str[pos + 1] if k == "1" or not k.isdigit(): return (None, None, None, None, None) - ident = bech[pos + 2:pos + 6] - share_index = bech[pos + 6] + ident = ms32_str[pos + 2:pos + 6] + share_index = ms32_str[pos + 6] if k == "0" and share_index != "s": return (None, None, None, None, None) - data = [CHARSET.find(x) for x in bech[pos + 1:]] + data = [CHARSET.find(x) for x in ms32_str[pos + 1:]] checksum_length = 13 if len(data) < 95 else 15 if not ms32_verify_checksum(data): return (None, None, None, None, None) @@ -166,7 +182,15 @@ def ms32_decode(bech): def convertbits(data, frombits, tobits, pad=True): - """General power-of-2 base conversion.""" + """ + General power-of-2 base conversion. + + :param data: List of integers to be converted. + :param frombits: Original base's bit size. + :param tobits: Target base's bit size. + :param pad: Whether to pad the result, defaults to True. + :return: List of integers in target base, or None on failure. + """ acc = 0 bits = 0 ret = [] @@ -189,7 +213,15 @@ def convertbits(data, frombits, tobits, pad=True): def decode(hrp, codex_str): - """Decode a codex32 string.""" + """ + Decode a codex32 string. + + :param hrp: Human-readable part of the codex32 string. i.e.: 'ms'. + :param codex_str: Codex32 string to be decoded. + :return: Tuple: k, ident, share index, decoded bytes. + If decoding fails, (None, None, None, None). + """ + hrpgot, k, ident, share_index, data = ms32_decode(codex_str) if hrpgot != hrp: return (None, None, None, None) @@ -201,15 +233,21 @@ def decode(hrp, codex_str): return k, ident, share_index, bytes(decoded) -### Beginning of Codex32 Generation Implementation ### - - def encode(hrp, k, ident, share_index, payload): - """Encode a codex32 string""" + """ + Encode a codex32 string. + + :param hrp: Human-readable part of the codex32 string. + :param k: Threshold parameter as a string. + :param ident: Identifier as a string. + :param share_index: Share index as a string. + :param payload: Payload data to be encoded. + :return: Codex32 string or None if encoding fails during validation. + """ if share_index.lower() == 's': # add double sha256 hash byte to pad seeds - checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest() + checksum = sha256(sha256(payload).digest()).digest() else: - checksum = b'0x00' # TODO: use a reed solomon or bch binary ECCcode for padding. + checksum = b'0x00' # TODO: use a reed solomon or bch binary ECCcode for padding. data = convertbits(payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in k + ident + share_index] + data) if decode(hrp, ret) == (None, None, None, None): @@ -217,208 +255,324 @@ def encode(hrp, k, ident, share_index, payload): return ret -def recover_master_seed(share_list = []): - """Recover a master seed from a threshold of valid codex32 shares.""" - ms32_share_list = [ms32_decode(share)[4] for share in share_list] - return bytes(convertbits(ms32_recover(ms32_share_list)[6:],5,8, False)) +def validate_codex32_string_list(string_list, k_must_equal_list_length=True): + """ + Validate uniform threshold, identifier, length, and unique indices. + :param string_list: List of codex32 strings to be validated. + :param k_must_equal_list_length: Flag for k must match list length. + :return: List of decoded data if valid, else None. + """ + list_len = len(string_list) + headers = set() + share_indices = set() + lengths = set() + + for codex32_string in string_list: + headers.add(tuple(decode("ms", codex32_string)[:2])) + share_indices.add(decode("ms", codex32_string)[2]) + lengths.add(len(codex32_string)) + if len(headers) > 1 or len(lengths) > 1: + return None -def derive_additional_share(codex32_string_list = [], fresh_share_index = "s"): - """Derive additional share at distinct new index from a threshold of valid codex32 strings.""" - ms32_share_list = [ms32_decode(string)[4] for string in codex32_string_list] - ms32_share_index = CHARSET.find(fresh_share_index.lower()) - return ms32_encode('ms', ms32_interpolate(ms32_share_list, ms32_share_index)) - - -def masterkey_identifier(master_seed): - """Get the 20 byte key identifier of the master pubkey""" - from electrum.bip32 import BIP32Node - from electrum.crypto import hash_160 - root_node = BIP32Node.from_rootseed(master_seed, xtype="stanadrd") - return hash_160(root_node.eckey.get_public_key_bytes(compressed=True)) - -def fingerprint(seed): - """Get the 4 byte bip32 fingerprint of a seed.""" - from electrum.bip32 import BIP32Node - bip32_fingerprint = BIP32Node.from_rootseed(seed, xtype="stanadrd").calc_fingerprint_of_this_node() - print(bip32_fingerprint) - bech32_fingerprint = ''.join([CHARSET[d] for d in convertbits(bip32_fingerprint, 8,5)])[:4] - return bip32_fingerprint, bech32_fingerprint - - -def encrypt_ident(master_seed, k, unique_string): - salt = len(master_seed).to_bytes(1, 'big') + bytes('ms1' + k, 'utf') - encryption_key = hashlib.pbkdf2_hmac('sha512', password=bytes(unique_string, 'utf'), - salt=salt, iterations=2048, dklen=4) - ident_bytes = fingerprint(master_seed)[0] - new_ident_bytes = bytes([x ^ y for x, y in zip(ident_bytes, encryption_key)]) - return ''.join([CHARSET[d] for d in convertbits(new_ident_bytes, 8, 5)])[:4] - - -def relabel_shares(hrp, share_list, new_ident): - """Change the ident on a list of shares.""" - new_share_list = [] - for share in share_list: - k, ident, share_index, decoded = decode(hrp, share) - new_share_list += [encode(hrp, k, new_ident, share_index, decoded)] - return new_share_list - - -def fresh_master_seed(bitcoin_core_entropy, user_entropy = '', seed_length = 16, k = '2', ident = '', n = 31): - """Derive a fresh master seed of seed length bytes with optional user-provided entropy.""" - # implementations must unconditionally display "App Entropy" for auditing - if 16 > seed_length > 64: + if (len(share_indices) < list_len + or k_must_equal_list_length and int(headers.pop()[0]) != list_len): return None - master_seed = hashlib.scrypt(password=bytes(user_entropy + str(seed_length) + k + ident, "utf"), - salt=bytes(bitcoin_core_entropy, "utf"), n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=seed_length) - return existing_master_seed(master_seed, k, n, ident, False) + return [ms32_decode(codex32_string)[4] for codex32_string in string_list] -def existing_master_seed(master_seed, k, n, ident = '', unique_string = ''): - """Derive n new set of n shares deterministically from master seed.""" - if k == "1" or not k.isdigit(): + +def recover_master_seed(share_list=[]): + """ + Derive master seed from a list of threshold valid codex32 shares. + + :param share_list: List of codex32 shares to recover master seed. + :return: The master seed as bytes, or None if share set is invalid. + """ + ms32_share_list = validate_codex32_string_list(share_list) + if not ms32_share_list: return None - if int(k) > n: + return bytes(convertbits(ms32_recover(ms32_share_list)[6:], 5, 8, False)) + + +def derive_share(string_list, fresh_share_index="s"): + """ + Derive an additional share at a distinct new index from a threshold + of valid codex32 strings. + + :param string_list: List of codex32 strings to derive from. + :param fresh_share_index: New index character for derived share. + :return: Derived codex32 share string or None if derivation fails. + """ + ms32_share_index = CHARSET.find(fresh_share_index.lower()) + if ms32_share_index < 0: return None - if not ident: - ident = encrypt_ident(master_seed, k, unique_string) - codex32_secret = encode('ms', k, ident, 's', master_seed) - if k == '0': - return [codex32_secret] * n - return existing_codex32_secret(codex32_secret, new_k = k, n = n, unique_string = unique_string) + ms32_string_list = validate_codex32_string_list(string_list) + return ms32_encode('ms', ms32_interpolate(ms32_string_list, ms32_share_index)) -def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): - """Shuffle indices deterministically using provided entropy uses: HMAC-SHA256. +def ms32_fingerprint(seed): + """ + Calculate and convert the BIP32 fingerprint of a seed to MS32. - Args: - index_seed (bytes): The seed used for deterministic shuffling. - indexes (str): Characters to be shuffled (default: CHARSET without 's'). + :param seed: The master seed used to derive the fingerprint. + :return: List of 4 base32 integers representing the fingerprint. + """ + return convertbits(BIP32Node.from_rootseed( + seed, xtype='standard').calc_fingerprint_of_this_node(), 8, 5)[:4] - Returns: - list: Shuffled characters sorted based on assigned values. + +def relabel_codex32_strings(hrp, string_list, new_k='', new_id=''): + """ + Change the k and ident on a list of codex32 strings. + + :param hrp: Human-readable part of the codex32 strings. + :param string_list: List of codex32 strings to be relabeled. + :param new_k: New threshold parameter as a string, if provided. + :param new_id: New identifier as a string, if provided. + :return: List of relabeled codex32 strings. + """ + new_strings = [] + for codex32_string in string_list: + k, ident, share_index, decoded = decode(hrp, codex32_string) + new_k = k if not new_k else new_k + new_id = ident if not new_id else new_id + new_strings.append(encode(hrp, new_k, new_id, share_index, decoded)) + return new_strings + + +def shuffle_indices(index_seed, indices=CHARSET.replace('s', '')): """ - counter = 0 # Counter to track the current position in the keystream - digest = b'' # Storage for HMAC digest - value = b'' # Storage for the assigned random value - assigned_values = {} # Dictionary to store characters and their values - for char in indexes: - # Ensure a new random value is generated whenever there is a collision + Shuffle indices deterministically using provided key with ChaCha20. + + :param index_seed: The ChaCha20 key for deterministic shuffling. + :param indices: Characters to be shuffled as a string. + :return: List of shuffled characters sorted by assigned values. + """ + + algorithm = algorithms.ChaCha20(index_seed, bytes(16)) + keystream = Cipher(algorithm, mode=None).encryptor() + counter = 0 # Counter to track current position in the keystream. + value = b'' # Storage for the assigned random byte. + assigned_values = {} # Dictionary to store chars and their values. + for char in indices: + # Ensure new random value is generated if there is a collision. while value in assigned_values.values() or not value: - if not counter % 32: # Generate a new digest every 32 bytes - digest = hmac.new(index_seed, (counter // 32).to_bytes(8, "big"), hashlib.sha256).digest() - value = digest[counter % 32 : counter % 32 + 1] # assign 1 random byte per count + if not counter % 64: # Get new 64-byte block per 64 count. + block = keystream.update(bytes(64)) # ChaCha20 block. + value = block[counter % 64: counter % 64 + 1] # Rand byte. counter += 1 assigned_values[char] = value return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) -def shuffle_indices(index_seed, indexes = CHARSET.replace('s', '')): - """Shuffle indices deterministically using provided entropy uses: ChaCha20. +def generate_shares(master_key='', user_entropy='', n=31, k='2', ident='NOID', + seed_length=16, existing_codex32_strings=[]): + """ + Generate new codex32 shares from provided or derived entropy. + + :param master_key: BIP32 extended private master key from bitcoind. + :param user_entropy: User-provided entropy for improved security. + :param n: Total number of codex32 shares to generate (default: 31). + :param k: Threshold parameter (default: 2). + :param ident: Identifier (4 bech32 characters) or 'NOID' (default). + :param seed_length: Length of seed (16 to 64 bytes, default: 16). + :param existing_codex32_strings: List of existing codex32 strings. + :return: Tuple: master_seed (bytes), list of new codex32 shares. + """ + new_shares = [] + num_strings = len(existing_codex32_strings) + if existing_codex32_strings and not validate_codex32_string_list( + existing_codex32_strings, False): + return None + available_indices = list(CHARSET) + for string in existing_codex32_strings: + k, ident, share_index, payload = decode('ms', string) + available_indices.remove(share_index) + if share_index == 's': + master_seed = payload + seed_length = len(payload) + + if num_strings == int(k) and not master_seed: + master_seed = recover_master_seed(existing_codex32_strings) + if master_seed: + master_key = BIP32Node.from_rootseed(master_seed, xtype='standard') + elif master_key: + master_key = BIP32Node.from_xkey(master_key) + else: + return None + key_identifier = hash_160(master_key.eckey.get_public_key_bytes()) + entropy_header = (seed_length.to_bytes(length=1, byteorder='big') + + bytes('ms' + k + ident + 's', 'utf') + key_identifier) + salt = entropy_header + bytes(CHARSET[n] + user_entropy, 'utf') + # This is equivalent to hmac-sha512(b"Bitcoin seed", master_seed). + password = master_key.eckey.get_secret_bytes() + master_key.chaincode + # If scrypt is unavailable OWASP Password Storage, use pbkdf2_hmac( + # 'sha512', password, salt, iterations=210_000 * 64, dklen=128) + derived_key = scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, + maxmem=2 ** 31 - 1, dklen=128) + # I hope this works! TODO: Verify that it works. + index_seed = hmac.digest(derived_key, b'Index seed', 'SHA512_256') + available_indices.remove('s') + available_indices = shuffle_indices(index_seed, available_indices) + ident = 'temp' if ident == 'NOID' else ident + + # Generate new shares, if necessary, to reach a threshold. + for i in range(num_strings, int(k)): + share_index = available_indices.pop() + info = 'Share payload with index ' + share_index + # TODO: If sha512/256 works use it here if seed_length < 33. + payload = hmac.digest(derived_key, info, 'sha512')[:seed_length] + new_shares.append(encode('ms', k, ident, share_index, payload)) + existing_codex32_strings.extend(new_shares) + master_seed = recover_master_seed(existing_codex32_strings) + if ident == 'temp': + ident = ''.join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) + relabel_codex32_strings('ms', existing_codex32_strings, k, ident) + + # Derive new shares using ms32_interpolate. + for i in range(int(k), n): + fresh_share_index = available_indices.pop() + new_share = derive_share(existing_codex32_strings, fresh_share_index) + new_shares.append(new_share) + + return master_seed, new_shares + + +def ident_encryption_key(payload, k, unique_string=''): + """ + Generate an MS32 encryption key from unique string and header data. + + :param payload: Payload for getting the length component of header. + :param k: Threshold component of header for key generation. + :param unique_string: Optional unique string to avoid ident reuse. + :return: Four symbol MS32 Encryption key derived from parameters. + """ + password = bytes(unique_string, 'utf') + salt = len(payload).to_bytes(1, 'big') + bytes('ms1' + k, 'utf') + return convertbits(pbkdf2_hmac('sha512', password, salt, iterations=2048, + dklen=3), 8, 5, pad=False) + + +def encrypt_fingerprint(master_seed, k, unique_string=''): + """ + Encrypt the MS32 fingerprint using a unique string and header data. + + :param master_seed: The master seed used for fingerprint. + :param k: The threshold parameter as a string. + :param unique_string: Optional unique string encryption password. + :return: Encrypted fingerprint as a bech32 string. + """ + enc_key = ident_encryption_key(master_seed, k, unique_string) + new_id = [x ^ y for x, y in zip(ms32_fingerprint(master_seed), enc_key)] + return ''.join([CHARSET[d] for d in new_id]) + + +def regenerate_shares(existing_codex32_strings, unique_string, + monotonic_counter, n=31, new_id=''): + """ + Regenerate fresh shares for an existing master seed & update ident. + + :param existing_codex32_strings: List of existing codex32 strings. + :param unique_string: Unique string for entropy. + :param monotonic_counter: Hardware or app monotonic counter value. + :param n: Number of shares to generate, default is 31. + :param new_ident: New identifier, if provided. + :return: List of regenerated codex32 shares. + """ + master_seed, new_shares = generate_shares( + user_entropy=unique_string + f'{monotonic_counter:016x}', n=n, + existing_codex32_strings=existing_codex32_strings) + k, ident, _, _ = decode('ms', new_shares[0]) + if not new_id or new_id != ident: + new_id = encrypt_fingerprint(master_seed, k, unique_string) + return relabel_codex32_strings('ms', new_shares, new_id=new_id) + + + password = bytes(unique_string, 'utf') + salt = len(master_seed).to_bytes(1, 'big') + bytes('ms' + k, 'utf') + enc_key = convertbits(pbkdf2_hmac('sha512', password=password, salt=salt, + iterations=2048, dklen=3), 8, 5, False) + ident = [x ^ y for x, y in zip(ms32_fingerprint(master_seed), enc_key)] + return ''.join([CHARSET[d] for d in ident]) + + +def decrypt_ident(codex32_string, unique_string=''): + """ + Decrypt a codex32 string identifier ciphertext using unique string. + + :param codex32_string: Codex32 string with an encrypted identifier. + :param unique_string: Optional unique string encryption password. + :return: Tuple with decrypted identifier (hex and MS32 string). + """ + k, ident, _, data = decode('ms', codex32_string) + enc_key = ident_encryption_key(data, k, unique_string) + ciphertext = [CHARSET.find(x) for x in ident] + plaintext = [x ^ y for x, y in zip(ciphertext, enc_key)] + return (convertbits(plaintext, 5, 8).hex()[:5], + ''.join([CHARSET[d] for d in plaintext])) + + + + +def shuffle_indexes(index_seed, indices=CHARSET.replace('s', '')): + """Shuffle indices deterministically using provided entropy uses: HMAC-SHA256. Args: index_seed (bytes): The seed used for deterministic shuffling. - indexes (str): Characters to be shuffled (default: CHARSET without 's'). + indices (str): Characters to be shuffled (default: CHARSET without 's'). Returns: list: Shuffled characters sorted based on assigned values. + + Provided only as a reference in case ChaCha20 is unavailable. """ - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms - algorithm = algorithms.ChaCha20(index_seed, bytes(16)) - keystream = Cipher(algorithm, mode=None).encryptor() - counter = 0 # Counter to track the current position in the keystream - value = b'' # Storage for the assigned random value - assigned_values = {} # Dictionary to store characters and their values - for char in indexes: - # Ensure a new random value is generated whenever there is a collision + counter = 0 # Counter to track current position in the keystream. + digest = b'' # Storage for HMAC-SHA256 digest + value = b'' # Storage for the assigned random value + assigned_values = {} # Dictionary to store characters and values. + for char in indices: + # Generates a new random value when there's a collision. while value in assigned_values.values() or not value: - if not counter % 64: # Generate a new 64-byte block every 64 bytes - block = keystream.update(bytes(64)) # Storage for ChaCha20 block - value = block[counter % 64 : counter % 64 + 1] # assign 1 random byte per count + if not counter % 32: # Generate new digest every 32 bytes. + digest = hmac.digest( + index_seed, (counter // 32).to_bytes(8, "big"), sha256) + value = digest[counter % 32: counter % 32 + 1] # rand byte counter += 1 assigned_values[char] = value return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) -def existing_codex32_secret(codex32_secret, new_k = '', new_ident = '', n = 31, unique_string = ''): - """Derive a fresh set of n shares deterministically from master seed. - This implementation encrypts the fingerprint identifier of the provided - codex32 secret with a unique string. Allows changing k.""" - shuffled_share_list = [] - k, ident, share_index, master_seed = decode('ms', codex32_secret) - k = new_k if new_k != k else k - seed_length = len(master_seed) - header = codex32_secret[:9] - key_identifier = masterkey_identifier(master_seed) # gets full hash160(pub_masterkey) - salt = bytes(header + unique_string, 'utf') + key_identifier # using old codex32 secret header in salt for reshare ident! - if unique_string or new_k or new_ident: - if not new_ident: - # encrypt the old ident by the date - new_ident = encrypt_ident(master_seed, k, unique_string) - ident = new_ident - codex32_secret = encode('ms', k, ident, share_index, master_seed) - salt = bytes(codex32_secret[:9] + unique_string, 'utf') + key_identifier # use the new header for derived key - derived_key = hashlib.pbkdf2_hmac('sha512', password=master_seed, salt=salt, - iterations=2048, dklen=64) - codex32_string_list = [codex32_secret] - for i in range(int(k) - 1): - info = bytes('Share ' + CHARSET[i], 'utf') - payload = hmac.digest(derived_key, info, hashlib.sha512)[:seed_length] - codex32_string_list += [encode('ms', k, ident, CHARSET[i], payload)] - index_seed = hmac.digest(derived_key, b'Index seed', hashlib.sha256) - shuffled_indices = shuffle_indices(index_seed, CHARSET.replace(share_index, '')) - for j in range(n): - shuffled_share_list += [derive_additional_share(codex32_string_list, shuffled_indices[j])] - return shuffled_share_list - - -def decrypt_ident(codex32_string, unique_string = ''): - """Returns bech32 fingerprint if unique string corresponds to the share and share is correct.""" - k, ident, share_index, payload = decode('ms', codex32_string) - salt = len(payload).to_bytes(1, 'big') + bytes('ms1' + k, 'utf') - encryption_key = hashlib.pbkdf2_hmac('sha512', password=bytes(unique_string, 'utf'), - salt=salt, iterations=2048, dklen=4) - ciphertext = bytes(convertbits([CHARSET.find(x) for x in ident], 5, 8, True)) - plaintext = bytes([x ^ y for x, y in zip(ciphertext, encryption_key)]) - return ''.join([CHARSET[d] for d in convertbits(plaintext, 8,5)])[:4] - - - def kdf_share(passphrase, codex32_share): """Derive codex32 share from a passphrase and the header of another share.""" import random salt = bytes(codex32_share[:8], "utf") seed_length = len(decode('ms1', codex32_share)[3]) - pw_hash = hashlib.scrypt(password=bytes(passphrase, "utf"), salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, - dklen=seed_length) + pw_hash = scrypt(password=bytes(passphrase, "utf"), salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, + dklen=seed_length) passphrase_index_seed = hmac.digest(pw_hash, 'Passphrase Share Index Seed') - shuffled_indices = shuffle_indices(passphrase_index_seed ,CHARSET.replace('s', ''))[0] + shuffled_indices = shuffle_indices(passphrase_index_seed, CHARSET.replace('s', ''))[0] indices_free = shuffled_indices[1:] codex32_kdf_share = encode("ms", seed_length, k, ident, kdf_share_index, list(pw_hash)) return (codex32_kdf_share, indices_free, salt) -def recover_master_seed(share_list=[]): - """Derive codex32 secret from a threshold of shares.""" - k = [None] * len(share_list) - ident = [None] * len(share_list) - share_index = [None] * len(share_list) - decoded = [None] * len(share_list) - for i in range(len(share_list)): - k[i], ident[i], share_index[i], decoded[i] = decode("ms", share_list[i]) - if k.count(k[0]) != len(k) or ident.count(ident[0]) != len(ident): - return (None) - if len({len(i) for i in decoded}) != 1: - return (None) - return recover(share_list, 's') - - - -# master_seed = fresh_master_seed(16,'Walletasdfpassword34','L5EZftvrYaSudiozVRzTqLcHLNDoVn7H5HSfM9BAN6tMJX8oTWz6') -seed = bytes(16) -share_list = existing_master_seed(seed, k = '2', n=2, ident = '', unique_string = 'a') -print(share_list) -print(len(share_list[0])) - -print(decrypt_ident('ms12mm2y8v7yuccjj2e0rvs8av2jlef3rvhmkem347ue40qr', 'a')) - +def ident_verify_checksum(codex32_secret): + """Verify an identifier checksum in a codex32 secret.""" + k, ident, share_index, decoded = decode("ms", codex32_secret) + hash_id = seed_ident(bytes(decoded)) + if share_index != 's' or hash_id[:3] != ident[:3]: + print('1') + return False + print('0') + return True + + +def verify_checksum(codex32_string): + """Verify a codex32 checksum in a codex32 string.""" + k, ident, share_index, decoded = decode("ms", codex32_string) + if decoded == None or len(decoded) < 16: + print('1') + return False + print('0') + return True From 5baff8727b126d738e6055dceafa80ff77abc07e Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 25 Aug 2023 09:01:10 -0500 Subject: [PATCH 13/31] scrypt for ident encryption, remove extra lines --- .idea/.gitignore | 3 +++ .idea/codex32.iml | 12 +++++++++ .../inspectionProfiles/profiles_settings.xml | 6 +++++ .idea/misc.xml | 4 +++ .idea/modules.xml | 8 ++++++ .idea/vcs.xml | 6 +++++ reference/python-codex32/src/lib.py | 27 +++++++------------ 7 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/codex32.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/codex32.iml b/.idea/codex32.iml new file mode 100644 index 0000000..8a05c6e --- /dev/null +++ b/.idea/codex32.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..dc9ea49 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..c9bec70 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 09f3125..4698233 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -7,7 +7,7 @@ from electrum.bip32 import BIP32Node from electrum.crypto import hash_160 -# ChaCha20 used for encrypting ident and a better keystream option for shuffle +# ChaCha20 used for better keystream option for shuffle from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from cryptography.hazmat.primitives.hashes import SHA512_256 @@ -410,7 +410,7 @@ def generate_shares(master_key='', user_entropy='', n=31, k='2', ident='NOID', # If scrypt is unavailable OWASP Password Storage, use pbkdf2_hmac( # 'sha512', password, salt, iterations=210_000 * 64, dklen=128) derived_key = scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, - maxmem=2 ** 31 - 1, dklen=128) + maxmem=1025 ** 3, dklen=128) # I hope this works! TODO: Verify that it works. index_seed = hmac.digest(derived_key, b'Index seed', 'SHA512_256') available_indices.remove('s') @@ -450,8 +450,8 @@ def ident_encryption_key(payload, k, unique_string=''): """ password = bytes(unique_string, 'utf') salt = len(payload).to_bytes(1, 'big') + bytes('ms1' + k, 'utf') - return convertbits(pbkdf2_hmac('sha512', password, salt, iterations=2048, - dklen=3), 8, 5, pad=False) + return convertbits(scrypt(password, salt, n=2**20, r=8, p=1, + maxmem=1025 ** 3, dklen=3), 8, 5, pad=False) def encrypt_fingerprint(master_seed, k, unique_string=''): @@ -489,14 +489,6 @@ def regenerate_shares(existing_codex32_strings, unique_string, return relabel_codex32_strings('ms', new_shares, new_id=new_id) - password = bytes(unique_string, 'utf') - salt = len(master_seed).to_bytes(1, 'big') + bytes('ms' + k, 'utf') - enc_key = convertbits(pbkdf2_hmac('sha512', password=password, salt=salt, - iterations=2048, dklen=3), 8, 5, False) - ident = [x ^ y for x, y in zip(ms32_fingerprint(master_seed), enc_key)] - return ''.join([CHARSET[d] for d in ident]) - - def decrypt_ident(codex32_string, unique_string=''): """ Decrypt a codex32 string identifier ciphertext using unique string. @@ -544,17 +536,16 @@ def shuffle_indexes(index_seed, indices=CHARSET.replace('s', '')): def kdf_share(passphrase, codex32_share): - """Derive codex32 share from a passphrase and the header of another share.""" - import random - salt = bytes(codex32_share[:8], "utf") + """Derive codex32 share from a passphrase and the share set header.""" + salt = len(codex32_share).to_bytes(1, 'big') + codex32_share[:8], "utf") seed_length = len(decode('ms1', codex32_share)[3]) pw_hash = scrypt(password=bytes(passphrase, "utf"), salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=seed_length) passphrase_index_seed = hmac.digest(pw_hash, 'Passphrase Share Index Seed') - shuffled_indices = shuffle_indices(passphrase_index_seed, CHARSET.replace('s', ''))[0] + shuffled_indices = shuffle_indices(passphrase_index_seed))[0] indices_free = shuffled_indices[1:] - codex32_kdf_share = encode("ms", seed_length, k, ident, kdf_share_index, list(pw_hash)) - return (codex32_kdf_share, indices_free, salt) + codex32_kdf_share = encode("ms", k, ident, kdf_share_index, pw_hash) + return codex32_kdf_share def ident_verify_checksum(codex32_secret): From 7f734a5082b0d2087fa637b612e8f377f10ee15b Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 25 Aug 2023 18:17:25 -0500 Subject: [PATCH 14/31] formatting refactor + add ecc_padding, fix imports --- .github/workflows/qodana_code_quality.yml | 19 ++ .idea/codeStyles/Project.xml | 16 + .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/inspectionProfiles/Project_Default.xml | 30 ++ .../inspectionProfiles/profiles_settings.xml | 6 - .idea/misc.xml | 4 + qodana.yaml | 29 ++ reference/python-codex32/src/lib.py | 278 ++++++++++-------- 8 files changed, 256 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/qodana_code_quality.yml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 qodana.yaml diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml new file mode 100644 index 0000000..5d0c836 --- /dev/null +++ b/.github/workflows/qodana_code_quality.yml @@ -0,0 +1,19 @@ +name: Qodana +on: + workflow_dispatch: + pull_request: + push: + branches: + - master + +jobs: + qodana: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2023.2 + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..e62bc98 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..61456d3 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,30 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index dc9ea49..766891a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,8 @@ + + \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..76fb7ad --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,29 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-:latest diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 4698233..e4016ff 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -2,18 +2,17 @@ # Author: Leon Olsson Curr and Pearlwort Sneed # License: BSD-3-Clause -from hashlib import scrypt, pbkdf2_hmac, sha256, sha512 +import hashlib import hmac -from electrum.bip32 import BIP32Node -from electrum.crypto import hash_160 # ChaCha20 used for better keystream option for shuffle from cryptography.hazmat.primitives.ciphers import Cipher, algorithms -from cryptography.hazmat.primitives.hashes import SHA512_256 +from electrum.bip32 import BIP32Node +from electrum.crypto import hash_160 CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" -MS32_CONST = 0x10ce0795c2fd1e62a -MS32_LONG_CONST = 0x43381e570bf4798ab26 +MS32_CONST = 0x10CE0795C2FD1E62A +MS32_LONG_CONST = 0x43381E570BF4798AB26 bech32_inv = [ 0, 1, 20, 24, 10, 8, 12, 29, 5, 11, 4, 9, 6, 28, 26, 31, 22, 18, 17, 23, 2, 25, 16, 19, 3, 21, 14, 30, 13, 7, 27, 15, @@ -22,16 +21,16 @@ def ms32_polymod(values): GEN = [ - 0x19dc500ce73fde210, - 0x1bfae00def77fe529, - 0x1fbd920fffe7bee52, - 0x1739640bdeee3fdad, - 0x07729a039cfc75f5a, + 0x19DC500CE73FDE210, + 0x1BFAE00DEF77FE529, + 0x1FBD920FFFE7BEE52, + 0x1739640BDEEE3FDAD, + 0x07729A039CFC75F5A, ] - residue = 0x23181b3 + residue = 0x23181B3 for v in values: - b = (residue >> 60) - residue = (residue & 0x0fffffffffffffff) << 5 ^ v + b = residue >> 60 + residue = (residue & 0x0FFFFFFFFFFFFFFF) << 5 ^ v for i in range(5): residue ^= GEN[i] if ((b >> i) & 1) else 0 return residue @@ -55,16 +54,16 @@ def ms32_create_checksum(data): def ms32_long_polymod(values): GEN = [ - 0x3d59d273535ea62d897, - 0x7a9becb6361c6c51507, - 0x543f9b7e6c38d8a2a0e, - 0x0c577eaeccf1990d13c, - 0x1887f74f8dc71b10651, + 0x3D59D273535EA62D897, + 0x7A9BECB6361C6C51507, + 0x543F9B7E6C38D8A2A0E, + 0x0C577EAECCF1990D13C, + 0x1887F74F8DC71B10651, ] - residue = 0x23181b3 + residue = 0x23181B3 for v in values: - b = (residue >> 70) - residue = (residue & 0x3fffffffffffffffff) << 5 ^ v + b = residue >> 70 + residue = (residue & 0x3FFFFFFFFFFFFFFFFF) << 5 ^ v for i in range(5): residue ^= GEN[i] if ((b >> i) & 1) else 0 return residue @@ -146,7 +145,7 @@ def ms32_encode(hrp, data): :return: MS32 encoded string with the given HRP and data. """ combined = data + ms32_create_checksum(data) - return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + return hrp + "1" + "".join([CHARSET[d] for d in combined]) def ms32_decode(ms32_str): @@ -157,28 +156,28 @@ def ms32_decode(ms32_str): :return: Tuple: HRP, k, ident, share index, data. If invalid, (None, None, None, None, None) """ - if ((any(ord(x) < 33 or ord(x) > 126 for x in ms32_str)) or - (ms32_str.lower() != ms32_str and ms32_str.upper() != ms32_str)): - return (None, None, None, None, None) + if ((any(ord(x) < 33 or ord(x) > 126 for x in ms32_str)) + or ms32_str.lower() != ms32_str and ms32_str.upper() != ms32_str): + return None, None, None, None, None ms32_str = ms32_str.lower() - pos = ms32_str.rfind('1') + pos = ms32_str.rfind("1") if pos < 1 or pos + 46 > len(ms32_str): - return (None, None, None, None, None) + return None, None, None, None, None if not all(x in CHARSET for x in ms32_str[pos + 1:]): - return (None, None, None, None, None) + return None, None, None, None, None hrp = ms32_str[:pos] k = ms32_str[pos + 1] if k == "1" or not k.isdigit(): - return (None, None, None, None, None) - ident = ms32_str[pos + 2:pos + 6] + return None, None, None, None, None + ident = ms32_str[pos + 2: pos + 6] share_index = ms32_str[pos + 6] if k == "0" and share_index != "s": - return (None, None, None, None, None) + return None, None, None, None, None data = [CHARSET.find(x) for x in ms32_str[pos + 1:]] checksum_length = 13 if len(data) < 95 else 15 if not ms32_verify_checksum(data): - return (None, None, None, None, None) - return (hrp, k, ident, share_index, data[:-checksum_length]) + return None, None, None, None, None + return hrp, k, ident, share_index, data[:-checksum_length] def convertbits(data, frombits, tobits, pad=True): @@ -216,23 +215,49 @@ def decode(hrp, codex_str): """ Decode a codex32 string. - :param hrp: Human-readable part of the codex32 string. i.e.: 'ms'. + :param hrp: Human-readable part of the codex32 string usually 'ms'. :param codex_str: Codex32 string to be decoded. :return: Tuple: k, ident, share index, decoded bytes. If decoding fails, (None, None, None, None). """ - hrpgot, k, ident, share_index, data = ms32_decode(codex_str) if hrpgot != hrp: - return (None, None, None, None) + return None, None, None, None decoded = convertbits(data[6:], 5, 8, False) if decoded is None or len(decoded) < 16 or len(decoded) > 64: - return (None, None, None, None) + return None, None, None, None if k == "1": - return (None, None, None, None) + return None, None, None, None return k, ident, share_index, bytes(decoded) +def ecc_padding(payload): + """ + Calculate and return a byte with concatenated parity bits. + + :param payload: Input byte data. + :return: Byte with concatenated parity bits. + """ + # Count the number of set (1) bits in the byte data + num_set_bits = sum(bin(byte)[2:].count("1") for byte in payload) + # Count the number of set (1) even bits in the byte data + num_set_even_bits = sum(bin(byte)[2::2].count("1") for byte in payload) + # Count the number of set (1) odd bits in the byte data + num_set_odd_bits = sum(bin(byte)[3::2].count("1") for byte in payload) + # Count the number of set (1) third bits in the byte data + num_set_third_bits = sum(bin(byte)[2::3].count("1") for byte in payload) + + # Determine the parity (even or odd) + parity_bit = num_set_bits % 2 + even_parity_bit = (num_set_even_bits % 2) << 1 + odd_parity_bit = (num_set_odd_bits % 2) << 2 + third_parity_bit = (num_set_third_bits % 2) << 3 + + combined_parity = (third_parity_bit | odd_parity_bit | even_parity_bit + | parity_bit) + return combined_parity.to_bytes(1, 'little') + + def encode(hrp, k, ident, share_index, payload): """ Encode a codex32 string. @@ -242,14 +267,13 @@ def encode(hrp, k, ident, share_index, payload): :param ident: Identifier as a string. :param share_index: Share index as a string. :param payload: Payload data to be encoded. - :return: Codex32 string or None if encoding fails during validation. + :return: Codex32 string or None if validation fails. """ - if share_index.lower() == 's': # add double sha256 hash byte to pad seeds - checksum = sha256(sha256(payload).digest()).digest() - else: - checksum = b'0x00' # TODO: use a reed solomon or bch binary ECCcode for padding. - data = convertbits(payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] - ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in k + ident + share_index] + data) + checksum = ecc_padding(payload) + data = convertbits( + payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] + ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in + k + ident + share_index] + data) if decode(hrp, ret) == (None, None, None, None): return None return ret @@ -275,14 +299,14 @@ def validate_codex32_string_list(string_list, k_must_equal_list_length=True): if len(headers) > 1 or len(lengths) > 1: return None - if (len(share_indices) < list_len - or k_must_equal_list_length and int(headers.pop()[0]) != list_len): + if (k_must_equal_list_length and int(headers.pop()[0]) != list_len + or len(share_indices) < list_len): return None return [ms32_decode(codex32_string)[4] for codex32_string in string_list] -def recover_master_seed(share_list=[]): +def recover_master_seed(share_list): """ Derive master seed from a list of threshold valid codex32 shares. @@ -297,18 +321,17 @@ def recover_master_seed(share_list=[]): def derive_share(string_list, fresh_share_index="s"): """ - Derive an additional share at a distinct new index from a threshold - of valid codex32 strings. + Derive an additional share from a valid codex32 string set. :param string_list: List of codex32 strings to derive from. - :param fresh_share_index: New index character for derived share. - :return: Derived codex32 share string or None if derivation fails. + :param fresh_share_index: New index character to derive share at. + :return: Derived codex32 share or None if derivation fails. """ ms32_share_index = CHARSET.find(fresh_share_index.lower()) if ms32_share_index < 0: return None - ms32_string_list = validate_codex32_string_list(string_list) - return ms32_encode('ms', ms32_interpolate(ms32_string_list, ms32_share_index)) + return ms32_encode("ms", ms32_interpolate( + validate_codex32_string_list(string_list), ms32_share_index)) def ms32_fingerprint(seed): @@ -319,10 +342,10 @@ def ms32_fingerprint(seed): :return: List of 4 base32 integers representing the fingerprint. """ return convertbits(BIP32Node.from_rootseed( - seed, xtype='standard').calc_fingerprint_of_this_node(), 8, 5)[:4] + seed, xtype="standard").calc_fingerprint_of_this_node(), 8, 5)[:4] -def relabel_codex32_strings(hrp, string_list, new_k='', new_id=''): +def relabel_codex32_strings(hrp, string_list, new_k="", new_id=""): """ Change the k and ident on a list of codex32 strings. @@ -341,33 +364,33 @@ def relabel_codex32_strings(hrp, string_list, new_k='', new_id=''): return new_strings -def shuffle_indices(index_seed, indices=CHARSET.replace('s', '')): +def shuffle_indices(index_seed, indices): """ Shuffle indices deterministically using provided key with ChaCha20. - + :param index_seed: The ChaCha20 key for deterministic shuffling. :param indices: Characters to be shuffled as a string. :return: List of shuffled characters sorted by assigned values. """ - algorithm = algorithms.ChaCha20(index_seed, bytes(16)) keystream = Cipher(algorithm, mode=None).encryptor() counter = 0 # Counter to track current position in the keystream. - value = b'' # Storage for the assigned random byte. + value = b"" # Storage for the assigned random byte. + block = b"" # Holds the latest keystream block. assigned_values = {} # Dictionary to store chars and their values. for char in indices: # Ensure new random value is generated if there is a collision. while value in assigned_values.values() or not value: if not counter % 64: # Get new 64-byte block per 64 count. block = keystream.update(bytes(64)) # ChaCha20 block. - value = block[counter % 64: counter % 64 + 1] # Rand byte. + value = block[counter % 64:counter % 64 + 1] # Rand byte. counter += 1 assigned_values[char] = value return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) -def generate_shares(master_key='', user_entropy='', n=31, k='2', ident='NOID', - seed_length=16, existing_codex32_strings=[]): +def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", + seed_length=16, existing_codex32_strings=None): """ Generate new codex32 shares from provided or derived entropy. @@ -380,55 +403,56 @@ def generate_shares(master_key='', user_entropy='', n=31, k='2', ident='NOID', :param existing_codex32_strings: List of existing codex32 strings. :return: Tuple: master_seed (bytes), list of new codex32 shares. """ + master_seed = b"" + if existing_codex32_strings is None: + existing_codex32_strings = [] new_shares = [] num_strings = len(existing_codex32_strings) - if existing_codex32_strings and not validate_codex32_string_list( - existing_codex32_strings, False): + if (validate_codex32_string_list(existing_codex32_strings, False) + and not existing_codex32_strings): return None available_indices = list(CHARSET) for string in existing_codex32_strings: - k, ident, share_index, payload = decode('ms', string) + k, ident, share_index, payload = decode("ms", string) available_indices.remove(share_index) - if share_index == 's': + if share_index == "s": master_seed = payload seed_length = len(payload) if num_strings == int(k) and not master_seed: master_seed = recover_master_seed(existing_codex32_strings) if master_seed: - master_key = BIP32Node.from_rootseed(master_seed, xtype='standard') + master_key = BIP32Node.from_rootseed(master_seed, xtype="standard") elif master_key: master_key = BIP32Node.from_xkey(master_key) else: return None key_identifier = hash_160(master_key.eckey.get_public_key_bytes()) - entropy_header = (seed_length.to_bytes(length=1, byteorder='big') - + bytes('ms' + k + ident + 's', 'utf') + key_identifier) - salt = entropy_header + bytes(CHARSET[n] + user_entropy, 'utf') + entropy_header = (seed_length.to_bytes(length=1, byteorder="big") + + bytes("ms" + k + ident + "s", "utf") + key_identifier) + salt = entropy_header + bytes(CHARSET[n] + user_entropy, "utf") # This is equivalent to hmac-sha512(b"Bitcoin seed", master_seed). password = master_key.eckey.get_secret_bytes() + master_key.chaincode - # If scrypt is unavailable OWASP Password Storage, use pbkdf2_hmac( + # If scrypt absent visit OWASP Password Storage or use pbkdf2_hmac( # 'sha512', password, salt, iterations=210_000 * 64, dklen=128) - derived_key = scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, - maxmem=1025 ** 3, dklen=128) - # I hope this works! TODO: Verify that it works. - index_seed = hmac.digest(derived_key, b'Index seed', 'SHA512_256') - available_indices.remove('s') + derived_key = hashlib.scrypt( + password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=128) + index_seed = hmac.digest(derived_key, b"Index seed", "sha512")[:32] + available_indices.remove("s") available_indices = shuffle_indices(index_seed, available_indices) - ident = 'temp' if ident == 'NOID' else ident + ident = "temp" if ident == "NOID" else ident # Generate new shares, if necessary, to reach a threshold. for i in range(num_strings, int(k)): share_index = available_indices.pop() - info = 'Share payload with index ' + share_index - # TODO: If sha512/256 works use it here if seed_length < 33. - payload = hmac.digest(derived_key, info, 'sha512')[:seed_length] - new_shares.append(encode('ms', k, ident, share_index, payload)) + info = "Share payload with index " + share_index + payload = hmac.digest(derived_key, info, "sha512")[:seed_length] + new_shares.append(encode("ms", k, ident, share_index, payload)) existing_codex32_strings.extend(new_shares) master_seed = recover_master_seed(existing_codex32_strings) - if ident == 'temp': - ident = ''.join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) - relabel_codex32_strings('ms', existing_codex32_strings, k, ident) + if ident == "temp": + ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) + relabel_codex32_strings("ms", existing_codex32_strings, k, ident) # Derive new shares using ms32_interpolate. for i in range(int(k), n): @@ -439,7 +463,7 @@ def generate_shares(master_key='', user_entropy='', n=31, k='2', ident='NOID', return master_seed, new_shares -def ident_encryption_key(payload, k, unique_string=''): +def ident_encryption_key(payload, k, unique_string=""): """ Generate an MS32 encryption key from unique string and header data. @@ -448,13 +472,14 @@ def ident_encryption_key(payload, k, unique_string=''): :param unique_string: Optional unique string to avoid ident reuse. :return: Four symbol MS32 Encryption key derived from parameters. """ - password = bytes(unique_string, 'utf') - salt = len(payload).to_bytes(1, 'big') + bytes('ms1' + k, 'utf') - return convertbits(scrypt(password, salt, n=2**20, r=8, p=1, - maxmem=1025 ** 3, dklen=3), 8, 5, pad=False) + password = bytes(unique_string, "utf") + salt = len(payload).to_bytes(1, "big") + bytes("ms1" + k, "utf") + return convertbits(hashlib.scrypt(password, salt=salt, n=2 ** 20, r=8, + p=1, maxmem=1025 ** 3, dklen=3), + 8, 5, pad=False) -def encrypt_fingerprint(master_seed, k, unique_string=''): +def encrypt_fingerprint(master_seed, k, unique_string=""): """ Encrypt the MS32 fingerprint using a unique string and header data. @@ -465,11 +490,11 @@ def encrypt_fingerprint(master_seed, k, unique_string=''): """ enc_key = ident_encryption_key(master_seed, k, unique_string) new_id = [x ^ y for x, y in zip(ms32_fingerprint(master_seed), enc_key)] - return ''.join([CHARSET[d] for d in new_id]) + return "".join([CHARSET[d] for d in new_id]) def regenerate_shares(existing_codex32_strings, unique_string, - monotonic_counter, n=31, new_id=''): + monotonic_counter, n=31, new_id=""): """ Regenerate fresh shares for an existing master seed & update ident. @@ -477,19 +502,19 @@ def regenerate_shares(existing_codex32_strings, unique_string, :param unique_string: Unique string for entropy. :param monotonic_counter: Hardware or app monotonic counter value. :param n: Number of shares to generate, default is 31. - :param new_ident: New identifier, if provided. + :param new_id: New identifier, if provided. :return: List of regenerated codex32 shares. """ master_seed, new_shares = generate_shares( - user_entropy=unique_string + f'{monotonic_counter:016x}', n=n, + user_entropy=unique_string + f"{monotonic_counter:016x}", n=n, existing_codex32_strings=existing_codex32_strings) - k, ident, _, _ = decode('ms', new_shares[0]) + k, ident, _, _ = decode("ms", new_shares[0]) if not new_id or new_id != ident: new_id = encrypt_fingerprint(master_seed, k, unique_string) - return relabel_codex32_strings('ms', new_shares, new_id=new_id) + return relabel_codex32_strings("ms", new_shares, new_id=new_id) -def decrypt_ident(codex32_string, unique_string=''): +def decrypt_ident(codex32_string, unique_string=""): """ Decrypt a codex32 string identifier ciphertext using unique string. @@ -497,31 +522,31 @@ def decrypt_ident(codex32_string, unique_string=''): :param unique_string: Optional unique string encryption password. :return: Tuple with decrypted identifier (hex and MS32 string). """ - k, ident, _, data = decode('ms', codex32_string) + k, ident, _, data = decode("ms", codex32_string) enc_key = ident_encryption_key(data, k, unique_string) ciphertext = [CHARSET.find(x) for x in ident] plaintext = [x ^ y for x, y in zip(ciphertext, enc_key)] - return (convertbits(plaintext, 5, 8).hex()[:5], - ''.join([CHARSET[d] for d in plaintext])) - + return bytes(convertbits(plaintext, 5, 8)).hex()[:5], "".join( + [CHARSET[d] for d in plaintext]) - -def shuffle_indexes(index_seed, indices=CHARSET.replace('s', '')): - """Shuffle indices deterministically using provided entropy uses: HMAC-SHA256. +def shuffle_indexes(index_seed, indices=CHARSET.replace("s", "")): + """Shuffle indices deterministically with index_seed: HMAC-SHA256. Args: index_seed (bytes): The seed used for deterministic shuffling. - indices (str): Characters to be shuffled (default: CHARSET without 's'). + indices (str): Characters to be shuffled. Returns: list: Shuffled characters sorted based on assigned values. Provided only as a reference in case ChaCha20 is unavailable. """ + from hashlib import sha256 + counter = 0 # Counter to track current position in the keystream. - digest = b'' # Storage for HMAC-SHA256 digest - value = b'' # Storage for the assigned random value + digest = b"" # Storage for HMAC-SHA256 digest + value = b"" # Storage for the assigned random value assigned_values = {} # Dictionary to store characters and values. for char in indices: # Generates a new random value when there's a collision. @@ -537,33 +562,36 @@ def shuffle_indexes(index_seed, indices=CHARSET.replace('s', '')): def kdf_share(passphrase, codex32_share): """Derive codex32 share from a passphrase and the share set header.""" - salt = len(codex32_share).to_bytes(1, 'big') + codex32_share[:8], "utf") - seed_length = len(decode('ms1', codex32_share)[3]) - pw_hash = scrypt(password=bytes(passphrase, "utf"), salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, - dklen=seed_length) - passphrase_index_seed = hmac.digest(pw_hash, 'Passphrase Share Index Seed') - shuffled_indices = shuffle_indices(passphrase_index_seed))[0] - indices_free = shuffled_indices[1:] - codex32_kdf_share = encode("ms", k, ident, kdf_share_index, pw_hash) - return codex32_kdf_share + k, ident, _, payload = decode("ms", codex32_share) + password = bytes(passphrase, "utf") + salt = len(payload).to_bytes(1, "big") + bytes(codex32_share[:8], "utf") + derived_key = hashlib.scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, + maxmem=1025 ** 3, dklen=128) + passphrase_index_seed = hmac.digest( + derived_key, b"Passphrase share index seed", "sha512")[:32] + share_index = shuffle_indices( + passphrase_index_seed, list(CHARSET.replace("s", ""))).pop() + payload = hmac.digest(derived_key, b"Passphrase share payload with index " + + bytes(share_index, "utf"), "sha512")[:len(payload)] + return encode("ms", k, ident, share_index, payload) def ident_verify_checksum(codex32_secret): """Verify an identifier checksum in a codex32 secret.""" k, ident, share_index, decoded = decode("ms", codex32_secret) - hash_id = seed_ident(bytes(decoded)) - if share_index != 's' or hash_id[:3] != ident[:3]: - print('1') + fingerprint_id = "".join([CHARSET[d] for d in ms32_fingerprint(decoded)]) + if share_index != "s" or fingerprint_id[:3] != ident[:3]: + print("1") return False - print('0') + print("0") return True def verify_checksum(codex32_string): """Verify a codex32 checksum in a codex32 string.""" k, ident, share_index, decoded = decode("ms", codex32_string) - if decoded == None or len(decoded) < 16: - print('1') + if decoded is None or len(decoded) < 16: + print("1") return False - print('0') + print("0") return True From 0e766c08b3f7ba898765c08e09bfd5d112ffe7e9 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:27:18 -0500 Subject: [PATCH 15/31] =?UTF-8?q?Update=20lib.py=20remove=20useless=20chec?= =?UTF-8?q?k=20if=20k=3D=3D=E2=80=981=E2=80=99=20from=20ms32=5Fdecode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It did nothing since the line prior checks that all data portion is in CHARSET and immediately returns None if not. --- reference/python-codex32/src/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index e4016ff..cec01bd 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -167,7 +167,7 @@ def ms32_decode(ms32_str): return None, None, None, None, None hrp = ms32_str[:pos] k = ms32_str[pos + 1] - if k == "1" or not k.isdigit(): + if not k.isdigit(): return None, None, None, None, None ident = ms32_str[pos + 2: pos + 6] share_index = ms32_str[pos + 6] From 7beef6f003a7a45432e15ef63916e9e155d2bdaf Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:35:24 -0500 Subject: [PATCH 16/31] Update lib.py --- reference/python-codex32/src/lib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index cec01bd..bf262aa 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -167,11 +167,9 @@ def ms32_decode(ms32_str): return None, None, None, None, None hrp = ms32_str[:pos] k = ms32_str[pos + 1] - if not k.isdigit(): - return None, None, None, None, None ident = ms32_str[pos + 2: pos + 6] share_index = ms32_str[pos + 6] - if k == "0" and share_index != "s": + if not k.isdigit() or k == "0" and share_index != "s": return None, None, None, None, None data = [CHARSET.find(x) for x in ms32_str[pos + 1:]] checksum_length = 13 if len(data) < 95 else 15 From bc4506ce75a13a553fc03ddadf3e5c63beebbc9c Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:48:43 -0500 Subject: [PATCH 17/31] =?UTF-8?q?Removed=20another=20useless=20k=3D=3D?= =?UTF-8?q?=E2=80=981=E2=80=99=20check.=20Update=20lib.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reference/python-codex32/src/lib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index cec01bd..630127a 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -226,8 +226,6 @@ def decode(hrp, codex_str): decoded = convertbits(data[6:], 5, 8, False) if decoded is None or len(decoded) < 16 or len(decoded) > 64: return None, None, None, None - if k == "1": - return None, None, None, None return k, ident, share_index, bytes(decoded) From d09833f72586237b1f58eecc266b9ff475aea627 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 26 Aug 2023 12:02:48 -0500 Subject: [PATCH 18/31] Update lib.py --- reference/python-codex32/src/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index b9df2ca..5d4db99 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -180,7 +180,7 @@ def ms32_decode(ms32_str): def convertbits(data, frombits, tobits, pad=True): """ - General power-of-2 base conversion. + Perform general power-of-2 base conversion. :param data: List of integers to be converted. :param frombits: Original base's bit size. From 3519a3e99cc05429a6d5d7b079f974d6dcfe4743 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 26 Aug 2023 17:08:59 -0500 Subject: [PATCH 19/31] refactor ecc_padding & ident_enc_key, fix id=temp --- reference/python-codex32/src/lib.py | 44 +++++++++++++---------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 5d4db99..148ab41 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -227,30 +227,24 @@ def decode(hrp, codex_str): return k, ident, share_index, bytes(decoded) -def ecc_padding(payload): +def ecc_padding(data): """ Calculate and return a byte with concatenated parity bits. - :param payload: Input byte data. + :param data: Bytes of seed_len, hrp, k, ident, index and payload. :return: Byte with concatenated parity bits. """ - # Count the number of set (1) bits in the byte data - num_set_bits = sum(bin(byte)[2:].count("1") for byte in payload) - # Count the number of set (1) even bits in the byte data - num_set_even_bits = sum(bin(byte)[2::2].count("1") for byte in payload) - # Count the number of set (1) odd bits in the byte data - num_set_odd_bits = sum(bin(byte)[3::2].count("1") for byte in payload) - # Count the number of set (1) third bits in the byte data - num_set_third_bits = sum(bin(byte)[2::3].count("1") for byte in payload) - - # Determine the parity (even or odd) - parity_bit = num_set_bits % 2 - even_parity_bit = (num_set_even_bits % 2) << 1 - odd_parity_bit = (num_set_odd_bits % 2) << 2 - third_parity_bit = (num_set_third_bits % 2) << 3 - - combined_parity = (third_parity_bit | odd_parity_bit | even_parity_bit - | parity_bit) + # Count mod 2 the number of set (1) bits in the byte data + parity_bit = sum(bin(byte)[2:].count("1") for byte in data) % 2 + # Count mod 2 the number of set (1) even bits in the byte data + even_parity_bit = sum(bin(byte)[2::2].count("1") for byte in data) % 2 + # Count mod 2 the number of set (1) odd bits in the byte data + odd_parity_bit = sum(bin(byte)[3::2].count("1") for byte in data) % 2 + # Count mod 2 the number of set (1) third bits in the byte data + third_parity_bit = sum(bin(byte)[2::3].count("1") for byte in data) % 2 + + combined_parity = (parity_bit + even_parity_bit * 2 + odd_parity_bit * 4 + + third_parity_bit * 8) return combined_parity.to_bytes(1, 'little') @@ -436,17 +430,17 @@ def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", index_seed = hmac.digest(derived_key, b"Index seed", "sha512")[:32] available_indices.remove("s") available_indices = shuffle_indices(index_seed, available_indices) - ident = "temp" if ident == "NOID" else ident + new_ident = "temp" if ident == "NOID" else ident # Generate new shares, if necessary, to reach a threshold. for i in range(num_strings, int(k)): share_index = available_indices.pop() info = "Share payload with index " + share_index payload = hmac.digest(derived_key, info, "sha512")[:seed_length] - new_shares.append(encode("ms", k, ident, share_index, payload)) + new_shares.append(encode("ms", k, new_ident, share_index, payload)) existing_codex32_strings.extend(new_shares) master_seed = recover_master_seed(existing_codex32_strings) - if ident == "temp": + if new_ident == "temp": ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) relabel_codex32_strings("ms", existing_codex32_strings, k, ident) @@ -470,9 +464,9 @@ def ident_encryption_key(payload, k, unique_string=""): """ password = bytes(unique_string, "utf") salt = len(payload).to_bytes(1, "big") + bytes("ms1" + k, "utf") - return convertbits(hashlib.scrypt(password, salt=salt, n=2 ** 20, r=8, - p=1, maxmem=1025 ** 3, dklen=3), - 8, 5, pad=False) + return convertbits(hashlib.scrypt( + password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=3), + 8, 5, pad=False) def encrypt_fingerprint(master_seed, k, unique_string=""): From b277759b8242c090907fa9dcb17b45ceab3315a0 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 27 Aug 2023 02:22:39 -0500 Subject: [PATCH 20/31] add header to ecc padding, fix validate_strings forgot to assign relabeled strings to existing_codex32_strings. --- reference/python-codex32/src/lib.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 148ab41..d09b7e2 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -259,7 +259,7 @@ def encode(hrp, k, ident, share_index, payload): :param payload: Payload data to be encoded. :return: Codex32 string or None if validation fails. """ - checksum = ecc_padding(payload) + checksum = ecc_padding(bytes(k + ident + share_index, "utf") + payload) data = convertbits( payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in @@ -398,8 +398,8 @@ def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", existing_codex32_strings = [] new_shares = [] num_strings = len(existing_codex32_strings) - if (validate_codex32_string_list(existing_codex32_strings, False) - and not existing_codex32_strings): + if (not validate_codex32_string_list(existing_codex32_strings, False) + and existing_codex32_strings): return None available_indices = list(CHARSET) for string in existing_codex32_strings: @@ -430,19 +430,20 @@ def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", index_seed = hmac.digest(derived_key, b"Index seed", "sha512")[:32] available_indices.remove("s") available_indices = shuffle_indices(index_seed, available_indices) - new_ident = "temp" if ident == "NOID" else ident + new_id = "temp" if ident == "NOID" else ident # Generate new shares, if necessary, to reach a threshold. for i in range(num_strings, int(k)): share_index = available_indices.pop() info = "Share payload with index " + share_index payload = hmac.digest(derived_key, info, "sha512")[:seed_length] - new_shares.append(encode("ms", k, new_ident, share_index, payload)) + new_shares.append(encode("ms", k, new_id, share_index, payload)) existing_codex32_strings.extend(new_shares) master_seed = recover_master_seed(existing_codex32_strings) - if new_ident == "temp": + if new_id == "temp": ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) - relabel_codex32_strings("ms", existing_codex32_strings, k, ident) + existing_codex32_strings = relabel_codex32_strings( + "ms", existing_codex32_strings, k, ident) # Derive new shares using ms32_interpolate. for i in range(int(k), n): From eb834138ea25b756a53c0bfc6a72aa5650a89a7e Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 27 Aug 2023 11:36:57 -0500 Subject: [PATCH 21/31] bytes(info), new_id => tmp_id, relabel new_shares info was being passed as a string but needed to be bytes. tmp_id is a better name than new_id, relabel new shares (there is never relabeling if existing shares were passed) --- reference/python-codex32/src/lib.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index d09b7e2..6d7d4bc 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -88,6 +88,7 @@ def bech32_mul(a, b): return res +# noinspection PyPep8 def bech32_lagrange(l, x): n = 1 c = [] @@ -430,20 +431,15 @@ def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", index_seed = hmac.digest(derived_key, b"Index seed", "sha512")[:32] available_indices.remove("s") available_indices = shuffle_indices(index_seed, available_indices) - new_id = "temp" if ident == "NOID" else ident + tmp_id = "temp" if ident == "NOID" else ident # Generate new shares, if necessary, to reach a threshold. for i in range(num_strings, int(k)): share_index = available_indices.pop() - info = "Share payload with index " + share_index + info = bytes("Share payload with index: " + share_index, "utf") payload = hmac.digest(derived_key, info, "sha512")[:seed_length] - new_shares.append(encode("ms", k, new_id, share_index, payload)) + new_shares.append(encode("ms", k, tmp_id, share_index, payload)) existing_codex32_strings.extend(new_shares) - master_seed = recover_master_seed(existing_codex32_strings) - if new_id == "temp": - ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) - existing_codex32_strings = relabel_codex32_strings( - "ms", existing_codex32_strings, k, ident) # Derive new shares using ms32_interpolate. for i in range(int(k), n): @@ -451,6 +447,12 @@ def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", new_share = derive_share(existing_codex32_strings, fresh_share_index) new_shares.append(new_share) + # Relabel the new shares with default ID. + master_seed = recover_master_seed(existing_codex32_strings) + if tmp_id == "temp": + ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) + new_shares = relabel_codex32_strings("ms", new_shares, k, ident) + return master_seed, new_shares From aaa33a06d39c1e62df2e259c5c3bbf3e66d0a2d2 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 27 Aug 2023 15:36:38 -0500 Subject: [PATCH 22/31] relabel can only be done on k strings, rest derive --- reference/python-codex32/src/lib.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/lib.py index 6d7d4bc..b8090f9 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/lib.py @@ -440,19 +440,18 @@ def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", payload = hmac.digest(derived_key, info, "sha512")[:seed_length] new_shares.append(encode("ms", k, tmp_id, share_index, payload)) existing_codex32_strings.extend(new_shares) - + # Relabel existing codex32 strings, if necessary, with default ID. + if tmp_id == "temp": + master_seed = recover_master_seed(existing_codex32_strings) + ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) + existing_codex32_strings = relabel_codex32_strings( + "ms", existing_codex32_strings, k, ident) # Derive new shares using ms32_interpolate. for i in range(int(k), n): fresh_share_index = available_indices.pop() new_share = derive_share(existing_codex32_strings, fresh_share_index) new_shares.append(new_share) - # Relabel the new shares with default ID. - master_seed = recover_master_seed(existing_codex32_strings) - if tmp_id == "temp": - ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) - new_shares = relabel_codex32_strings("ms", new_shares, k, ident) - return master_seed, new_shares From 7ec6a63b6d2c1c2c7fdd601a44019f3bfc7ee433 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 4 Sep 2023 01:41:58 -0500 Subject: [PATCH 23/31] renamed lib.py to codex32.py added docstrings for kdf_share() a Bails codex32 enabled feature. --- .github/workflows/qodana_code_quality.yml | 19 --------- qodana.yaml | 29 ------------- reference/python-codex32/Cargo.toml | 10 ----- .../python-codex32/src/{lib.py => codex32.py} | 41 +++++++------------ 4 files changed, 15 insertions(+), 84 deletions(-) delete mode 100644 .github/workflows/qodana_code_quality.yml delete mode 100644 qodana.yaml delete mode 100644 reference/python-codex32/Cargo.toml rename reference/python-codex32/src/{lib.py => codex32.py} (95%) diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml deleted file mode 100644 index 5d0c836..0000000 --- a/.github/workflows/qodana_code_quality.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Qodana -on: - workflow_dispatch: - pull_request: - push: - branches: - - master - -jobs: - qodana: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2023.2 - env: - QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml deleted file mode 100644 index 76fb7ad..0000000 --- a/qodana.yaml +++ /dev/null @@ -1,29 +0,0 @@ -#-------------------------------------------------------------------------------# -# Qodana analysis is configured by qodana.yaml file # -# https://www.jetbrains.com/help/qodana/qodana-yaml.html # -#-------------------------------------------------------------------------------# -version: "1.0" - -#Specify inspection profile for code analysis -profile: - name: qodana.starter - -#Enable inspections -#include: -# - name: - -#Disable inspections -#exclude: -# - name: -# paths: -# - - -#Execute shell command before Qodana execution (Applied in CI/CD pipeline) -#bootstrap: sh ./prepare-qodana.sh - -#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) -#plugins: -# - id: #(plugin id can be found at https://plugins.jetbrains.com) - -#Specify Qodana linter for analysis (Applied in CI/CD pipeline) -linter: jetbrains/qodana-:latest diff --git a/reference/python-codex32/Cargo.toml b/reference/python-codex32/Cargo.toml deleted file mode 100644 index 0d52b9a..0000000 --- a/reference/python-codex32/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "codex32" -version = "0.1.0" -edition = "2018" -description = "Python3 reference implementation of the codex32 spec" -license = "CC0-1.0" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] diff --git a/reference/python-codex32/src/lib.py b/reference/python-codex32/src/codex32.py similarity index 95% rename from reference/python-codex32/src/lib.py rename to reference/python-codex32/src/codex32.py index b8090f9..6a977bc 100644 --- a/reference/python-codex32/src/lib.py +++ b/reference/python-codex32/src/codex32.py @@ -490,7 +490,7 @@ def regenerate_shares(existing_codex32_strings, unique_string, """ Regenerate fresh shares for an existing master seed & update ident. - :param existing_codex32_strings: List of existing codex32 strings. + :param existing_codex32_strings: List of codex32 strings to reuse. :param unique_string: Unique string for entropy. :param monotonic_counter: Hardware or app monotonic counter value. :param n: Number of shares to generate, default is 31. @@ -552,11 +552,21 @@ def shuffle_indexes(index_seed, indices=CHARSET.replace("s", "")): return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) -def kdf_share(passphrase, codex32_share): - """Derive codex32 share from a passphrase and the share set header.""" - k, ident, _, payload = decode("ms", codex32_share) +def kdf_share(passphrase, codex32_str): + """ + Derive codex32 share from a passphrase and the codex32 header. + + Args: + passphrase: a seed backup passphrase as a string + codex32_str: a valid codex32 string to derive kdf share with. + + Returns: + the string encoded kdf_share + + """ + k, ident, _, payload = decode("ms", codex32_str) password = bytes(passphrase, "utf") - salt = len(payload).to_bytes(1, "big") + bytes(codex32_share[:8], "utf") + salt = len(payload).to_bytes(1, "big") + bytes(codex32_str[:8], "utf") derived_key = hashlib.scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=128) passphrase_index_seed = hmac.digest( @@ -566,24 +576,3 @@ def kdf_share(passphrase, codex32_share): payload = hmac.digest(derived_key, b"Passphrase share payload with index " + bytes(share_index, "utf"), "sha512")[:len(payload)] return encode("ms", k, ident, share_index, payload) - - -def ident_verify_checksum(codex32_secret): - """Verify an identifier checksum in a codex32 secret.""" - k, ident, share_index, decoded = decode("ms", codex32_secret) - fingerprint_id = "".join([CHARSET[d] for d in ms32_fingerprint(decoded)]) - if share_index != "s" or fingerprint_id[:3] != ident[:3]: - print("1") - return False - print("0") - return True - - -def verify_checksum(codex32_string): - """Verify a codex32 checksum in a codex32 string.""" - k, ident, share_index, decoded = decode("ms", codex32_string) - if decoded is None or len(decoded) < 16: - print("1") - return False - print("0") - return True From 415091221908155732c6f8afe1ad229b93055634 Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Mon, 4 Sep 2023 01:45:30 -0500 Subject: [PATCH 24/31] Delete Westgate's additions codex32.py Deleteing these so all new code from the BIP in line reference is in a new diff for review. --- reference/python-codex32/src/codex32.py | 470 ------------------------ 1 file changed, 470 deletions(-) diff --git a/reference/python-codex32/src/codex32.py b/reference/python-codex32/src/codex32.py index 6a977bc..be3d638 100644 --- a/reference/python-codex32/src/codex32.py +++ b/reference/python-codex32/src/codex32.py @@ -2,14 +2,6 @@ # Author: Leon Olsson Curr and Pearlwort Sneed # License: BSD-3-Clause -import hashlib -import hmac - -# ChaCha20 used for better keystream option for shuffle -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms -from electrum.bip32 import BIP32Node -from electrum.crypto import hash_160 - CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" MS32_CONST = 0x10CE0795C2FD1E62A MS32_LONG_CONST = 0x43381E570BF4798AB26 @@ -114,465 +106,3 @@ def ms32_interpolate(l, x): def ms32_recover(l): return ms32_interpolate(l, 16) - - -# Copyright (c) 2023 Ben Westgate -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - - -def ms32_encode(hrp, data): - """ - Compute an MS32 string. - - :param hrp: Human-readable part of the ms32 string, usually 'ms'. - :param data: List of base32 integers representing data to encode. - :return: MS32 encoded string with the given HRP and data. - """ - combined = data + ms32_create_checksum(data) - return hrp + "1" + "".join([CHARSET[d] for d in combined]) - - -def ms32_decode(ms32_str): - """ - Validate an MS32 string and extract components. - - :param ms32_str: The MS32 encoded string to be validated. - :return: Tuple: HRP, k, ident, share index, data. If invalid, - (None, None, None, None, None) - """ - if ((any(ord(x) < 33 or ord(x) > 126 for x in ms32_str)) - or ms32_str.lower() != ms32_str and ms32_str.upper() != ms32_str): - return None, None, None, None, None - ms32_str = ms32_str.lower() - pos = ms32_str.rfind("1") - if pos < 1 or pos + 46 > len(ms32_str): - return None, None, None, None, None - if not all(x in CHARSET for x in ms32_str[pos + 1:]): - return None, None, None, None, None - hrp = ms32_str[:pos] - k = ms32_str[pos + 1] - ident = ms32_str[pos + 2: pos + 6] - share_index = ms32_str[pos + 6] - if not k.isdigit() or k == "0" and share_index != "s": - return None, None, None, None, None - data = [CHARSET.find(x) for x in ms32_str[pos + 1:]] - checksum_length = 13 if len(data) < 95 else 15 - if not ms32_verify_checksum(data): - return None, None, None, None, None - return hrp, k, ident, share_index, data[:-checksum_length] - - -def convertbits(data, frombits, tobits, pad=True): - """ - Perform general power-of-2 base conversion. - - :param data: List of integers to be converted. - :param frombits: Original base's bit size. - :param tobits: Target base's bit size. - :param pad: Whether to pad the result, defaults to True. - :return: List of integers in target base, or None on failure. - """ - acc = 0 - bits = 0 - ret = [] - maxv = (1 << tobits) - 1 - max_acc = (1 << (frombits + tobits - 1)) - 1 - for value in data: - if value < 0 or (value >> frombits): - return None - acc = ((acc << frombits) | value) & max_acc - bits += frombits - while bits >= tobits: - bits -= tobits - ret.append((acc >> bits) & maxv) - if pad: - if bits: - ret.append((acc << (tobits - bits)) & maxv) - elif bits >= frombits: - return None - return ret - - -def decode(hrp, codex_str): - """ - Decode a codex32 string. - - :param hrp: Human-readable part of the codex32 string usually 'ms'. - :param codex_str: Codex32 string to be decoded. - :return: Tuple: k, ident, share index, decoded bytes. - If decoding fails, (None, None, None, None). - """ - hrpgot, k, ident, share_index, data = ms32_decode(codex_str) - if hrpgot != hrp: - return None, None, None, None - decoded = convertbits(data[6:], 5, 8, False) - if decoded is None or len(decoded) < 16 or len(decoded) > 64: - return None, None, None, None - return k, ident, share_index, bytes(decoded) - - -def ecc_padding(data): - """ - Calculate and return a byte with concatenated parity bits. - - :param data: Bytes of seed_len, hrp, k, ident, index and payload. - :return: Byte with concatenated parity bits. - """ - # Count mod 2 the number of set (1) bits in the byte data - parity_bit = sum(bin(byte)[2:].count("1") for byte in data) % 2 - # Count mod 2 the number of set (1) even bits in the byte data - even_parity_bit = sum(bin(byte)[2::2].count("1") for byte in data) % 2 - # Count mod 2 the number of set (1) odd bits in the byte data - odd_parity_bit = sum(bin(byte)[3::2].count("1") for byte in data) % 2 - # Count mod 2 the number of set (1) third bits in the byte data - third_parity_bit = sum(bin(byte)[2::3].count("1") for byte in data) % 2 - - combined_parity = (parity_bit + even_parity_bit * 2 + odd_parity_bit * 4 - + third_parity_bit * 8) - return combined_parity.to_bytes(1, 'little') - - -def encode(hrp, k, ident, share_index, payload): - """ - Encode a codex32 string. - - :param hrp: Human-readable part of the codex32 string. - :param k: Threshold parameter as a string. - :param ident: Identifier as a string. - :param share_index: Share index as a string. - :param payload: Payload data to be encoded. - :return: Codex32 string or None if validation fails. - """ - checksum = ecc_padding(bytes(k + ident + share_index, "utf") + payload) - data = convertbits( - payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] - ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in - k + ident + share_index] + data) - if decode(hrp, ret) == (None, None, None, None): - return None - return ret - - -def validate_codex32_string_list(string_list, k_must_equal_list_length=True): - """ - Validate uniform threshold, identifier, length, and unique indices. - - :param string_list: List of codex32 strings to be validated. - :param k_must_equal_list_length: Flag for k must match list length. - :return: List of decoded data if valid, else None. - """ - list_len = len(string_list) - headers = set() - share_indices = set() - lengths = set() - - for codex32_string in string_list: - headers.add(tuple(decode("ms", codex32_string)[:2])) - share_indices.add(decode("ms", codex32_string)[2]) - lengths.add(len(codex32_string)) - if len(headers) > 1 or len(lengths) > 1: - return None - - if (k_must_equal_list_length and int(headers.pop()[0]) != list_len - or len(share_indices) < list_len): - return None - - return [ms32_decode(codex32_string)[4] for codex32_string in string_list] - - -def recover_master_seed(share_list): - """ - Derive master seed from a list of threshold valid codex32 shares. - - :param share_list: List of codex32 shares to recover master seed. - :return: The master seed as bytes, or None if share set is invalid. - """ - ms32_share_list = validate_codex32_string_list(share_list) - if not ms32_share_list: - return None - return bytes(convertbits(ms32_recover(ms32_share_list)[6:], 5, 8, False)) - - -def derive_share(string_list, fresh_share_index="s"): - """ - Derive an additional share from a valid codex32 string set. - - :param string_list: List of codex32 strings to derive from. - :param fresh_share_index: New index character to derive share at. - :return: Derived codex32 share or None if derivation fails. - """ - ms32_share_index = CHARSET.find(fresh_share_index.lower()) - if ms32_share_index < 0: - return None - return ms32_encode("ms", ms32_interpolate( - validate_codex32_string_list(string_list), ms32_share_index)) - - -def ms32_fingerprint(seed): - """ - Calculate and convert the BIP32 fingerprint of a seed to MS32. - - :param seed: The master seed used to derive the fingerprint. - :return: List of 4 base32 integers representing the fingerprint. - """ - return convertbits(BIP32Node.from_rootseed( - seed, xtype="standard").calc_fingerprint_of_this_node(), 8, 5)[:4] - - -def relabel_codex32_strings(hrp, string_list, new_k="", new_id=""): - """ - Change the k and ident on a list of codex32 strings. - - :param hrp: Human-readable part of the codex32 strings. - :param string_list: List of codex32 strings to be relabeled. - :param new_k: New threshold parameter as a string, if provided. - :param new_id: New identifier as a string, if provided. - :return: List of relabeled codex32 strings. - """ - new_strings = [] - for codex32_string in string_list: - k, ident, share_index, decoded = decode(hrp, codex32_string) - new_k = k if not new_k else new_k - new_id = ident if not new_id else new_id - new_strings.append(encode(hrp, new_k, new_id, share_index, decoded)) - return new_strings - - -def shuffle_indices(index_seed, indices): - """ - Shuffle indices deterministically using provided key with ChaCha20. - - :param index_seed: The ChaCha20 key for deterministic shuffling. - :param indices: Characters to be shuffled as a string. - :return: List of shuffled characters sorted by assigned values. - """ - algorithm = algorithms.ChaCha20(index_seed, bytes(16)) - keystream = Cipher(algorithm, mode=None).encryptor() - counter = 0 # Counter to track current position in the keystream. - value = b"" # Storage for the assigned random byte. - block = b"" # Holds the latest keystream block. - assigned_values = {} # Dictionary to store chars and their values. - for char in indices: - # Ensure new random value is generated if there is a collision. - while value in assigned_values.values() or not value: - if not counter % 64: # Get new 64-byte block per 64 count. - block = keystream.update(bytes(64)) # ChaCha20 block. - value = block[counter % 64:counter % 64 + 1] # Rand byte. - counter += 1 - assigned_values[char] = value - return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) - - -def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", - seed_length=16, existing_codex32_strings=None): - """ - Generate new codex32 shares from provided or derived entropy. - - :param master_key: BIP32 extended private master key from bitcoind. - :param user_entropy: User-provided entropy for improved security. - :param n: Total number of codex32 shares to generate (default: 31). - :param k: Threshold parameter (default: 2). - :param ident: Identifier (4 bech32 characters) or 'NOID' (default). - :param seed_length: Length of seed (16 to 64 bytes, default: 16). - :param existing_codex32_strings: List of existing codex32 strings. - :return: Tuple: master_seed (bytes), list of new codex32 shares. - """ - master_seed = b"" - if existing_codex32_strings is None: - existing_codex32_strings = [] - new_shares = [] - num_strings = len(existing_codex32_strings) - if (not validate_codex32_string_list(existing_codex32_strings, False) - and existing_codex32_strings): - return None - available_indices = list(CHARSET) - for string in existing_codex32_strings: - k, ident, share_index, payload = decode("ms", string) - available_indices.remove(share_index) - if share_index == "s": - master_seed = payload - seed_length = len(payload) - - if num_strings == int(k) and not master_seed: - master_seed = recover_master_seed(existing_codex32_strings) - if master_seed: - master_key = BIP32Node.from_rootseed(master_seed, xtype="standard") - elif master_key: - master_key = BIP32Node.from_xkey(master_key) - else: - return None - key_identifier = hash_160(master_key.eckey.get_public_key_bytes()) - entropy_header = (seed_length.to_bytes(length=1, byteorder="big") - + bytes("ms" + k + ident + "s", "utf") + key_identifier) - salt = entropy_header + bytes(CHARSET[n] + user_entropy, "utf") - # This is equivalent to hmac-sha512(b"Bitcoin seed", master_seed). - password = master_key.eckey.get_secret_bytes() + master_key.chaincode - # If scrypt absent visit OWASP Password Storage or use pbkdf2_hmac( - # 'sha512', password, salt, iterations=210_000 * 64, dklen=128) - derived_key = hashlib.scrypt( - password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=128) - index_seed = hmac.digest(derived_key, b"Index seed", "sha512")[:32] - available_indices.remove("s") - available_indices = shuffle_indices(index_seed, available_indices) - tmp_id = "temp" if ident == "NOID" else ident - - # Generate new shares, if necessary, to reach a threshold. - for i in range(num_strings, int(k)): - share_index = available_indices.pop() - info = bytes("Share payload with index: " + share_index, "utf") - payload = hmac.digest(derived_key, info, "sha512")[:seed_length] - new_shares.append(encode("ms", k, tmp_id, share_index, payload)) - existing_codex32_strings.extend(new_shares) - # Relabel existing codex32 strings, if necessary, with default ID. - if tmp_id == "temp": - master_seed = recover_master_seed(existing_codex32_strings) - ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) - existing_codex32_strings = relabel_codex32_strings( - "ms", existing_codex32_strings, k, ident) - # Derive new shares using ms32_interpolate. - for i in range(int(k), n): - fresh_share_index = available_indices.pop() - new_share = derive_share(existing_codex32_strings, fresh_share_index) - new_shares.append(new_share) - - return master_seed, new_shares - - -def ident_encryption_key(payload, k, unique_string=""): - """ - Generate an MS32 encryption key from unique string and header data. - - :param payload: Payload for getting the length component of header. - :param k: Threshold component of header for key generation. - :param unique_string: Optional unique string to avoid ident reuse. - :return: Four symbol MS32 Encryption key derived from parameters. - """ - password = bytes(unique_string, "utf") - salt = len(payload).to_bytes(1, "big") + bytes("ms1" + k, "utf") - return convertbits(hashlib.scrypt( - password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=3), - 8, 5, pad=False) - - -def encrypt_fingerprint(master_seed, k, unique_string=""): - """ - Encrypt the MS32 fingerprint using a unique string and header data. - - :param master_seed: The master seed used for fingerprint. - :param k: The threshold parameter as a string. - :param unique_string: Optional unique string encryption password. - :return: Encrypted fingerprint as a bech32 string. - """ - enc_key = ident_encryption_key(master_seed, k, unique_string) - new_id = [x ^ y for x, y in zip(ms32_fingerprint(master_seed), enc_key)] - return "".join([CHARSET[d] for d in new_id]) - - -def regenerate_shares(existing_codex32_strings, unique_string, - monotonic_counter, n=31, new_id=""): - """ - Regenerate fresh shares for an existing master seed & update ident. - - :param existing_codex32_strings: List of codex32 strings to reuse. - :param unique_string: Unique string for entropy. - :param monotonic_counter: Hardware or app monotonic counter value. - :param n: Number of shares to generate, default is 31. - :param new_id: New identifier, if provided. - :return: List of regenerated codex32 shares. - """ - master_seed, new_shares = generate_shares( - user_entropy=unique_string + f"{monotonic_counter:016x}", n=n, - existing_codex32_strings=existing_codex32_strings) - k, ident, _, _ = decode("ms", new_shares[0]) - if not new_id or new_id != ident: - new_id = encrypt_fingerprint(master_seed, k, unique_string) - return relabel_codex32_strings("ms", new_shares, new_id=new_id) - - -def decrypt_ident(codex32_string, unique_string=""): - """ - Decrypt a codex32 string identifier ciphertext using unique string. - - :param codex32_string: Codex32 string with an encrypted identifier. - :param unique_string: Optional unique string encryption password. - :return: Tuple with decrypted identifier (hex and MS32 string). - """ - k, ident, _, data = decode("ms", codex32_string) - enc_key = ident_encryption_key(data, k, unique_string) - ciphertext = [CHARSET.find(x) for x in ident] - plaintext = [x ^ y for x, y in zip(ciphertext, enc_key)] - return bytes(convertbits(plaintext, 5, 8)).hex()[:5], "".join( - [CHARSET[d] for d in plaintext]) - - -def shuffle_indexes(index_seed, indices=CHARSET.replace("s", "")): - """Shuffle indices deterministically with index_seed: HMAC-SHA256. - - Args: - index_seed (bytes): The seed used for deterministic shuffling. - indices (str): Characters to be shuffled. - - Returns: - list: Shuffled characters sorted based on assigned values. - - Provided only as a reference in case ChaCha20 is unavailable. - """ - from hashlib import sha256 - - counter = 0 # Counter to track current position in the keystream. - digest = b"" # Storage for HMAC-SHA256 digest - value = b"" # Storage for the assigned random value - assigned_values = {} # Dictionary to store characters and values. - for char in indices: - # Generates a new random value when there's a collision. - while value in assigned_values.values() or not value: - if not counter % 32: # Generate new digest every 32 bytes. - digest = hmac.digest( - index_seed, (counter // 32).to_bytes(8, "big"), sha256) - value = digest[counter % 32: counter % 32 + 1] # rand byte - counter += 1 - assigned_values[char] = value - return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) - - -def kdf_share(passphrase, codex32_str): - """ - Derive codex32 share from a passphrase and the codex32 header. - - Args: - passphrase: a seed backup passphrase as a string - codex32_str: a valid codex32 string to derive kdf share with. - - Returns: - the string encoded kdf_share - - """ - k, ident, _, payload = decode("ms", codex32_str) - password = bytes(passphrase, "utf") - salt = len(payload).to_bytes(1, "big") + bytes(codex32_str[:8], "utf") - derived_key = hashlib.scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, - maxmem=1025 ** 3, dklen=128) - passphrase_index_seed = hmac.digest( - derived_key, b"Passphrase share index seed", "sha512")[:32] - share_index = shuffle_indices( - passphrase_index_seed, list(CHARSET.replace("s", ""))).pop() - payload = hmac.digest(derived_key, b"Passphrase share payload with index " - + bytes(share_index, "utf"), "sha512")[:len(payload)] - return encode("ms", k, ident, share_index, payload) From 312dc59cf68573200bb77d51074997f077fe971f Mon Sep 17 00:00:00 2001 From: Ben Westgate <73506583+BenWestgate@users.noreply.github.com> Date: Mon, 4 Sep 2023 01:47:46 -0500 Subject: [PATCH 25/31] Add Westgate's additions to the reference Update codex32.py Now they should all be reviewable in one diff. --- reference/python-codex32/src/codex32.py | 469 ++++++++++++++++++++++++ 1 file changed, 469 insertions(+) diff --git a/reference/python-codex32/src/codex32.py b/reference/python-codex32/src/codex32.py index be3d638..7a3254f 100644 --- a/reference/python-codex32/src/codex32.py +++ b/reference/python-codex32/src/codex32.py @@ -2,6 +2,12 @@ # Author: Leon Olsson Curr and Pearlwort Sneed # License: BSD-3-Clause +import hashlib +import hmac +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms +from electrum.bip32 import BIP32Node +from electrum.crypto import hash_160 + CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" MS32_CONST = 0x10CE0795C2FD1E62A MS32_LONG_CONST = 0x43381E570BF4798AB26 @@ -104,5 +110,468 @@ def ms32_interpolate(l, x): return res +# Copyright (c) 2023 Ben Westgate +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +def ms32_encode(hrp, data): + """ + Compute an MS32 string. + + :param hrp: Human-readable part of the ms32 string, usually 'ms'. + :param data: List of base32 integers representing data to encode. + :return: MS32 encoded string with the given HRP and data. + """ + combined = data + ms32_create_checksum(data) + return hrp + "1" + "".join([CHARSET[d] for d in combined]) + + +def ms32_decode(ms32_str): + """ + Validate an MS32 string and extract components. + + :param ms32_str: The MS32 encoded string to be validated. + :return: Tuple: HRP, k, ident, share index, data. If invalid, + (None, None, None, None, None) + """ + if ((any(ord(x) < 33 or ord(x) > 126 for x in ms32_str)) + or ms32_str.lower() != ms32_str and ms32_str.upper() != ms32_str): + return None, None, None, None, None + ms32_str = ms32_str.lower() + pos = ms32_str.rfind("1") + if pos < 1 or pos + 46 > len(ms32_str): + return None, None, None, None, None + if not all(x in CHARSET for x in ms32_str[pos + 1:]): + return None, None, None, None, None + hrp = ms32_str[:pos] + k = ms32_str[pos + 1] + ident = ms32_str[pos + 2: pos + 6] + share_index = ms32_str[pos + 6] + if not k.isdigit() or k == "0" and share_index != "s": + return None, None, None, None, None + data = [CHARSET.find(x) for x in ms32_str[pos + 1:]] + checksum_length = 13 if len(data) < 95 else 15 + if not ms32_verify_checksum(data): + return None, None, None, None, None + return hrp, k, ident, share_index, data[:-checksum_length] + + +def convertbits(data, frombits, tobits, pad=True): + """ + Perform general power-of-2 base conversion. + + :param data: List of integers to be converted. + :param frombits: Original base's bit size. + :param tobits: Target base's bit size. + :param pad: Whether to pad the result, defaults to True. + :return: List of integers in target base, or None on failure. + """ + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits: + return None + return ret + + +def decode(hrp, codex_str): + """ + Decode a codex32 string. + + :param hrp: Human-readable part of the codex32 string usually 'ms'. + :param codex_str: Codex32 string to be decoded. + :return: Tuple: k, ident, share index, decoded bytes. + If decoding fails, (None, None, None, None). + """ + hrpgot, k, ident, share_index, data = ms32_decode(codex_str) + if hrpgot != hrp: + return None, None, None, None + decoded = convertbits(data[6:], 5, 8, False) + if decoded is None or len(decoded) < 16 or len(decoded) > 64: + return None, None, None, None + return k, ident, share_index, bytes(decoded) + + +def ecc_padding(data): + """ + Calculate and return a byte with concatenated parity bits. + + :param data: Bytes of seed_len, hrp, k, ident, index and payload. + :return: Byte with concatenated parity bits. + """ + # Count mod 2 the number of set (1) bits in the byte data + parity_bit = sum(bin(byte)[2:].count("1") for byte in data) % 2 + # Count mod 2 the number of set (1) even bits in the byte data + even_parity_bit = sum(bin(byte)[2::2].count("1") for byte in data) % 2 + # Count mod 2 the number of set (1) odd bits in the byte data + odd_parity_bit = sum(bin(byte)[3::2].count("1") for byte in data) % 2 + # Count mod 2 the number of set (1) third bits in the byte data + third_parity_bit = sum(bin(byte)[2::3].count("1") for byte in data) % 2 + + combined_parity = (parity_bit + even_parity_bit * 2 + odd_parity_bit * 4 + + third_parity_bit * 8) + return combined_parity.to_bytes(1, 'little') + + +def encode(hrp, k, ident, share_index, payload): + """ + Encode a codex32 string. + + :param hrp: Human-readable part of the codex32 string. + :param k: Threshold parameter as a string. + :param ident: Identifier as a string. + :param share_index: Share index as a string. + :param payload: Payload data to be encoded. + :return: Codex32 string or None if validation fails. + """ + checksum = ecc_padding(bytes(k + ident + share_index, "utf") + payload) + data = convertbits( + payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] + ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in + k + ident + share_index] + data) + if decode(hrp, ret) == (None, None, None, None): + return None + return ret + + +def validate_codex32_string_list(string_list, k_must_equal_list_length=True): + """ + Validate uniform threshold, identifier, length, and unique indices. + + :param string_list: List of codex32 strings to be validated. + :param k_must_equal_list_length: Flag for k must match list length. + :return: List of decoded data if valid, else None. + """ + list_len = len(string_list) + headers = set() + share_indices = set() + lengths = set() + + for codex32_string in string_list: + headers.add(tuple(decode("ms", codex32_string)[:2])) + share_indices.add(decode("ms", codex32_string)[2]) + lengths.add(len(codex32_string)) + if len(headers) > 1 or len(lengths) > 1: + return None + + if (k_must_equal_list_length and int(headers.pop()[0]) != list_len + or len(share_indices) < list_len): + return None + + return [ms32_decode(codex32_string)[4] for codex32_string in string_list] + + +def recover_master_seed(share_list): + """ + Derive master seed from a list of threshold valid codex32 shares. + + :param share_list: List of codex32 shares to recover master seed. + :return: The master seed as bytes, or None if share set is invalid. + """ + ms32_share_list = validate_codex32_string_list(share_list) + if not ms32_share_list: + return None + return bytes(convertbits(ms32_recover(ms32_share_list)[6:], 5, 8, False)) + + +def derive_share(string_list, fresh_share_index="s"): + """ + Derive an additional share from a valid codex32 string set. + + :param string_list: List of codex32 strings to derive from. + :param fresh_share_index: New index character to derive share at. + :return: Derived codex32 share or None if derivation fails. + """ + ms32_share_index = CHARSET.find(fresh_share_index.lower()) + if ms32_share_index < 0: + return None + return ms32_encode("ms", ms32_interpolate( + validate_codex32_string_list(string_list), ms32_share_index)) + + +def ms32_fingerprint(seed): + """ + Calculate and convert the BIP32 fingerprint of a seed to MS32. + + :param seed: The master seed used to derive the fingerprint. + :return: List of 4 base32 integers representing the fingerprint. + """ + return convertbits(BIP32Node.from_rootseed( + seed, xtype="standard").calc_fingerprint_of_this_node(), 8, 5)[:4] + + +def relabel_codex32_strings(hrp, string_list, new_k="", new_id=""): + """ + Change the k and ident on a list of codex32 strings. + + :param hrp: Human-readable part of the codex32 strings. + :param string_list: List of codex32 strings to be relabeled. + :param new_k: New threshold parameter as a string, if provided. + :param new_id: New identifier as a string, if provided. + :return: List of relabeled codex32 strings. + """ + new_strings = [] + for codex32_string in string_list: + k, ident, share_index, decoded = decode(hrp, codex32_string) + new_k = k if not new_k else new_k + new_id = ident if not new_id else new_id + new_strings.append(encode(hrp, new_k, new_id, share_index, decoded)) + return new_strings + + +def shuffle_indices(index_seed, indices): + """ + Shuffle indices deterministically using provided key with ChaCha20. + + :param index_seed: The ChaCha20 key for deterministic shuffling. + :param indices: Characters to be shuffled as a string. + :return: List of shuffled characters sorted by assigned values. + """ + algorithm = algorithms.ChaCha20(index_seed, bytes(16)) + keystream = Cipher(algorithm, mode=None).encryptor() + counter = 0 # Counter to track current position in the keystream. + value = b"" # Storage for the assigned random byte. + block = b"" # Holds the latest keystream block. + assigned_values = {} # Dictionary to store chars and their values. + for char in indices: + # Ensure new random value is generated if there is a collision. + while value in assigned_values.values() or not value: + if not counter % 64: # Get new 64-byte block per 64 count. + block = keystream.update(bytes(64)) # ChaCha20 block. + value = block[counter % 64:counter % 64 + 1] # Rand byte. + counter += 1 + assigned_values[char] = value + return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) + + +def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", + seed_length=16, existing_codex32_strings=None): + """ + Generate new codex32 shares from provided or derived entropy. + + :param master_key: BIP32 extended private master key from bitcoind. + :param user_entropy: User-provided entropy for improved security. + :param n: Total number of codex32 shares to generate (default: 31). + :param k: Threshold parameter (default: 2). + :param ident: Identifier (4 bech32 characters) or 'NOID' (default). + :param seed_length: Length of seed (16 to 64 bytes, default: 16). + :param existing_codex32_strings: List of existing codex32 strings. + :return: Tuple: master_seed (bytes), list of new codex32 shares. + """ + master_seed = b"" + if existing_codex32_strings is None: + existing_codex32_strings = [] + new_shares = [] + num_strings = len(existing_codex32_strings) + if (not validate_codex32_string_list(existing_codex32_strings, False) + and existing_codex32_strings): + return None + available_indices = list(CHARSET) + for string in existing_codex32_strings: + k, ident, share_index, payload = decode("ms", string) + available_indices.remove(share_index) + if share_index == "s": + master_seed = payload + seed_length = len(payload) + + if num_strings == int(k) and not master_seed: + master_seed = recover_master_seed(existing_codex32_strings) + if master_seed: + master_key = BIP32Node.from_rootseed(master_seed, xtype="standard") + elif master_key: + master_key = BIP32Node.from_xkey(master_key) + else: + return None + key_identifier = hash_160(master_key.eckey.get_public_key_bytes()) + entropy_header = (seed_length.to_bytes(length=1, byteorder="big") + + bytes("ms" + k + ident + "s", "utf") + key_identifier) + salt = entropy_header + bytes(CHARSET[n] + user_entropy, "utf") + # This is equivalent to hmac-sha512(b"Bitcoin seed", master_seed). + password = master_key.eckey.get_secret_bytes() + master_key.chaincode + # If scrypt absent visit OWASP Password Storage or use pbkdf2_hmac( + # 'sha512', password, salt, iterations=210_000 * 64, dklen=128) + derived_key = hashlib.scrypt( + password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=128) + index_seed = hmac.digest(derived_key, b"Index seed", "sha512")[:32] + available_indices.remove("s") + available_indices = shuffle_indices(index_seed, available_indices) + tmp_id = "temp" if ident == "NOID" else ident + + # Generate new shares, if necessary, to reach a threshold. + for i in range(num_strings, int(k)): + share_index = available_indices.pop() + info = bytes("Share payload with index: " + share_index, "utf") + payload = hmac.digest(derived_key, info, "sha512")[:seed_length] + new_shares.append(encode("ms", k, tmp_id, share_index, payload)) + existing_codex32_strings.extend(new_shares) + # Relabel existing codex32 strings, if necessary, with default ID. + if tmp_id == "temp": + master_seed = recover_master_seed(existing_codex32_strings) + ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) + existing_codex32_strings = relabel_codex32_strings( + "ms", existing_codex32_strings, k, ident) + # Derive new shares using ms32_interpolate. + for i in range(int(k), n): + fresh_share_index = available_indices.pop() + new_share = derive_share(existing_codex32_strings, fresh_share_index) + new_shares.append(new_share) + + return master_seed, new_shares + + +def ident_encryption_key(payload, k, unique_string=""): + """ + Generate an MS32 encryption key from unique string and header data. + + :param payload: Payload for getting the length component of header. + :param k: Threshold component of header for key generation. + :param unique_string: Optional unique string to avoid ident reuse. + :return: Four symbol MS32 Encryption key derived from parameters. + """ + password = bytes(unique_string, "utf") + salt = len(payload).to_bytes(1, "big") + bytes("ms1" + k, "utf") + return convertbits(hashlib.scrypt( + password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=3), + 8, 5, pad=False) + + +def encrypt_fingerprint(master_seed, k, unique_string=""): + """ + Encrypt the MS32 fingerprint using a unique string and header data. + + :param master_seed: The master seed used for fingerprint. + :param k: The threshold parameter as a string. + :param unique_string: Optional unique string encryption password. + :return: Encrypted fingerprint as a bech32 string. + """ + enc_key = ident_encryption_key(master_seed, k, unique_string) + new_id = [x ^ y for x, y in zip(ms32_fingerprint(master_seed), enc_key)] + return "".join([CHARSET[d] for d in new_id]) + + +def regenerate_shares(existing_codex32_strings, unique_string, + monotonic_counter, n=31, new_id=""): + """ + Regenerate fresh shares for an existing master seed & update ident. + + :param existing_codex32_strings: List of codex32 strings to reuse. + :param unique_string: Unique string for entropy. + :param monotonic_counter: Hardware or app monotonic counter value. + :param n: Number of shares to generate, default is 31. + :param new_id: New identifier, if provided. + :return: List of regenerated codex32 shares. + """ + master_seed, new_shares = generate_shares( + user_entropy=unique_string + f"{monotonic_counter:016x}", n=n, + existing_codex32_strings=existing_codex32_strings) + k, ident, _, _ = decode("ms", new_shares[0]) + if not new_id or new_id != ident: + new_id = encrypt_fingerprint(master_seed, k, unique_string) + return relabel_codex32_strings("ms", new_shares, new_id=new_id) + + +def decrypt_ident(codex32_string, unique_string=""): + """ + Decrypt a codex32 string identifier ciphertext using unique string. + + :param codex32_string: Codex32 string with an encrypted identifier. + :param unique_string: Optional unique string encryption password. + :return: Tuple with decrypted identifier (hex and MS32 string). + """ + k, ident, _, data = decode("ms", codex32_string) + enc_key = ident_encryption_key(data, k, unique_string) + ciphertext = [CHARSET.find(x) for x in ident] + plaintext = [x ^ y for x, y in zip(ciphertext, enc_key)] + return bytes(convertbits(plaintext, 5, 8)).hex()[:5], "".join( + [CHARSET[d] for d in plaintext]) + + +def shuffle_indexes(index_seed, indices=CHARSET.replace("s", "")): + """Shuffle indices deterministically with index_seed: HMAC-SHA256. + + Args: + index_seed (bytes): The seed used for deterministic shuffling. + indices (str): Characters to be shuffled. + + Returns: + list: Shuffled characters sorted based on assigned values. + + Provided only as a reference in case ChaCha20 is unavailable. + """ + from hashlib import sha256 + + counter = 0 # Counter to track current position in the keystream. + digest = b"" # Storage for HMAC-SHA256 digest + value = b"" # Storage for the assigned random value + assigned_values = {} # Dictionary to store characters and values. + for char in indices: + # Generates a new random value when there's a collision. + while value in assigned_values.values() or not value: + if not counter % 32: # Generate new digest every 32 bytes. + digest = hmac.digest( + index_seed, (counter // 32).to_bytes(8, "big"), sha256) + value = digest[counter % 32: counter % 32 + 1] # rand byte + counter += 1 + assigned_values[char] = value + return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) + + +def kdf_share(passphrase, codex32_str): + """ + Derive codex32 share from a passphrase and the codex32 header. + + Args: + passphrase: a seed backup passphrase as a string + codex32_str: a valid codex32 string to derive kdf share with. + + Returns: + the string encoded kdf_share + + """ + k, ident, _, payload = decode("ms", codex32_str) + password = bytes(passphrase, "utf") + salt = len(payload).to_bytes(1, "big") + bytes(codex32_str[:8], "utf") + derived_key = hashlib.scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, + maxmem=1025 ** 3, dklen=128) + passphrase_index_seed = hmac.digest( + derived_key, b"Passphrase share index seed", "sha512")[:32] + share_index = shuffle_indices( + passphrase_index_seed, list(CHARSET.replace("s", ""))).pop() + payload = hmac.digest(derived_key, b"Passphrase share payload with index " + + bytes(share_index, "utf"), "sha512")[:len(payload)] + return encode("ms", k, ident, share_index, payload) + + + def ms32_recover(l): return ms32_interpolate(l, 16) From f4b7bdb48bc4c1ecd3e33c852eaeaa0168f0aee1 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 28 Oct 2023 10:16:26 -0500 Subject: [PATCH 26/31] Import instruction draft --- docs/wallets.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/wallets.md diff --git a/docs/wallets.md b/docs/wallets.md new file mode 100644 index 0000000..a0537d7 --- /dev/null +++ b/docs/wallets.md @@ -0,0 +1,82 @@ +# For Wallet Developers + +Codex32 is a new format for BIP32 master seeds. It is essentially a replacement for +BIP39 or SLIP39 seed words, and the user workflow should be much the same, except +that: + +* The character set is different (bech32 characters rather than a wordlist). +* Seeds may be split across multiple shares, rather than encoded as a single string. +* It is possible to do specific error detection/correction during entry. + +There are two levels of wallet support: + +* The ability to import seeds/shares; here we essentially just have recommendations about dealing with errors. +* The ability to generate seeds/shares on the device; here our guidance is more involved. + +We encourage every wallet to support importing seeds and shares, since +* the technical difficulty is low (roughly on par with that of supporting Segwit addresses, plus optional error-correction support); +* the added functionality is isolated from the rest of the wallet (once the seed is imported you don't care where it came from); and +* beyond correctness of the code, there is little risk (no need to source randomness or execute potentially variable-time algorithms). + +Supporting seed generation is a little more involved so the tradeoff between +implementation complexity and user value is less clear, especially since the +Codex provides users instructions on doing generation themselves. + +## Error Detection and Correction + +Wallets MUST support detection of errors using the codex32 checksum algorithm. +Wallets SHOULD additionally support error correction; such wallets are referred to as "error-correcting wallets (ECWs)" and have additional requirements. + +An ECW: + +* MUST support correction of up to 4 substitution errors and erasures; +* MAY also support correction of up to 8 erasures, 13 if contiguous; +* MAY support correction of further errors, including insertion or deletion errors. + +If a wallet is unable to meet these specifications, it is not an ECW and it SHOULD NOT expose error-correction functionality to the user. + +## Import Support + +Wallets MAY accept seeds whose length is any multiple of 8 between 128 and 512 bits, inclusive. +Wallets SHOULD support import of 128- and 256-bit seeds; other lengths are optional. + +128-bit seeds encode as 48-character codex32 strings, including the `MS1` prefix. +256-bit seeds encode as 74-character codex32 strings. For other bit-lengths, see the BIP. + +The process for entering codex32 strings is: + +1. The user should enter the first string. To the extent possible given screen limitations, data should be displayed in uppercase with visually distinct four-character windows. The first four-character window should include the `MS1` prefix, which should be pre-filled. + * The user should not be able to enter mixed-case characters. The user MUST be able to enter all bech32 characters. ECWs MUST also allow entry of `?` which indicates an erasure (unknown character). Wallets MAY allow users to enter non-bech32 characters, at their discretion. (This may be useful to guide error correction, by attempting to replace commonly confused characters.) + * If the header is invalid, the wallet SHOULD highlight this and request confirmation from the user before allowing additional data to be entered. An invalid header is one that starts with a character other than `0` or `2` through `9`, or one which starts with `0` but whose share index is not `S`. For shares after the first, a header is also invalid if its threshold and identifier do not match those of the first share. +1. Once the first string is fully entered, the wallet MUST validate the checksum and header before accepting it. + * If the checksum does not pass, then an ECW: + * MUST attempt error correction of substitution errors and erasures. + * MAY attempt correction by deleting and/or inserting characters, as long as the resulting string has a valid length for a codex32 string. + * MUST show a valid correction candidate, if found, to the user for confirmation rather than silently applying it. + * If insertion and/or deletion correction candidates are found, the shortest edit distance valid string SHOULD be displayed. + * ECWs displaying a candidate correction MAY highlight corrected 4-character windows and/or specific locations of corrections. +1. After the first string has been entered and accepted, the wallet now knows the identifier, threshold value and valid length. + * If the first string had index `S`, this was the codex32 secret and the import process is complete. + * Otherwise, the fourth character of the share will be a numeric character between `2` and `9` inclusive. The user must enter this many shares in total. + * Wallets MAY encrypt and store recovery progress, to allow recovery without having all shares available at once. The details of this are currently outside of the scope of this specification. +1. The user should then enter the remaining shares, in the same manner as the first. + * The wallet SHOULD pre-fill the header (threshold value and identifier). + * If the user tries to repeat an already-entered share index, they should be prevented from entering additional data until it is corrected. + * The wallet MAY guide the user by indicating that a share index has been repeated; + * ECWs may use `?` as a share index arbitrarily many times. If the user indicates above they are not repeating the share, the share index SHOULD be replaced by `?`. + * If the checksum fails, the wallet MAY attempt correction by deleting and/or inserting characters. However, the wallet MUST assume the valid length of all subsequent shares is equal to the valid length of the first share, so the number of characters inserted and deleted must net out to the correct length. +1. For all invalid codex32 strings entered, if an ECW is able to correct the errors (by deletion, insertion, substitution and/or filling erasures), the wallet MUST show the corrected string to the user and request confirmation that the corrected string **exactly matches** the user's copy of the data. The wallet MAY highlight the locations of changed characters or 4-character windows. The wallet MUST NOT silently apply corrections without approval from the user. + * If no valid string is found with a correct hrp, header and unique index within levenshtein distance 4 or within 30 seconds of search, give up. + * ECWs MAY warn the user they've repeated a share if the only valid string found exactly matches a previously entered one. + * Otherwise all wallets MUST not accept an invalid string and SHOULD inform the user it is invalid. + +1. Once all shares are entered, the wallet should recover the master seed and import this. + +**The master seed should be used directly as a master seed, as specified in BIP32.** +Unlike in BIP39 or other specifications, no PBKDF or other pre-processing should be applied. + +## Generate Support + +TODO + + From 62e999e8d5a1397e82c8161bd2ae31265747df9f Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 28 Oct 2023 10:17:08 -0500 Subject: [PATCH 27/31] Generation and Import python reference --- codex32.py | 582 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 582 insertions(+) create mode 100644 codex32.py diff --git a/codex32.py b/codex32.py new file mode 100644 index 0000000..d27555f --- /dev/null +++ b/codex32.py @@ -0,0 +1,582 @@ +#!/bin/python3 +# Author: Leon Olsson Curr and Pearlwort Sneed +# License: BSD-3-Clause + +import hashlib +import hmac + +# ChaCha20 used for better keystream option for shuffle +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms +from electrum.bip32 import BIP32Node +from electrum.crypto import hash_160 + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +MS32_CONST = 0x10CE0795C2FD1E62A +MS32_LONG_CONST = 0x43381E570BF4798AB26 +bech32_inv = [ + 0, 1, 20, 24, 10, 8, 12, 29, 5, 11, 4, 9, 6, 28, 26, 31, + 22, 18, 17, 23, 2, 25, 16, 19, 3, 21, 14, 30, 13, 7, 27, 15, +] + + +def ms32_polymod(values): + GEN = [ + 0x19DC500CE73FDE210, + 0x1BFAE00DEF77FE529, + 0x1FBD920FFFE7BEE52, + 0x1739640BDEEE3FDAD, + 0x07729A039CFC75F5A, + ] + residue = 0x23181B3 + for v in values: + b = residue >> 60 + residue = (residue & 0x0FFFFFFFFFFFFFFF) << 5 ^ v + for i in range(5): + residue ^= GEN[i] if ((b >> i) & 1) else 0 + return residue + + +def ms32_verify_checksum(data): + if len(data) >= 96: # See Long codex32 Strings + return ms32_verify_long_checksum(data) + if len(data) <= 93: + return ms32_polymod(data) == MS32_CONST + return False + + +def ms32_create_checksum(data): + if len(data) > 80: # See Long codex32 Strings + return ms32_create_long_checksum(data) + values = data + polymod = ms32_polymod(values + [0] * 13) ^ MS32_CONST + return [(polymod >> 5 * (12 - i)) & 31 for i in range(13)] + + +def ms32_long_polymod(values): + GEN = [ + 0x3D59D273535EA62D897, + 0x7A9BECB6361C6C51507, + 0x543F9B7E6C38D8A2A0E, + 0x0C577EAECCF1990D13C, + 0x1887F74F8DC71B10651, + ] + residue = 0x23181B3 + for v in values: + b = residue >> 70 + residue = (residue & 0x3FFFFFFFFFFFFFFFFF) << 5 ^ v + for i in range(5): + residue ^= GEN[i] if ((b >> i) & 1) else 0 + return residue + + +def ms32_verify_long_checksum(data): + return ms32_long_polymod(data) == MS32_LONG_CONST + + +def ms32_create_long_checksum(data): + values = data + polymod = ms32_long_polymod(values + [0] * 15) ^ MS32_LONG_CONST + return [(polymod >> 5 * (14 - i)) & 31 for i in range(15)] + + +def bech32_mul(a, b): + res = 0 + for i in range(5): + res ^= a if ((b >> i) & 1) else 0 + a *= 2 + a ^= 41 if (32 <= a) else 0 + return res + + +# noinspection PyPep8 +def bech32_lagrange(l, x): + n = 1 + c = [] + for i in l: + n = bech32_mul(n, i ^ x) + m = 1 + for j in l: + m = bech32_mul(m, (x if i == j else i) ^ j) + c.append(m) + return [bech32_mul(n, bech32_inv[i]) for i in c] + + +def ms32_interpolate(l, x): + w = bech32_lagrange([s[5] for s in l], x) + res = [] + for i in range(len(l[0])): + n = 0 + for j in range(len(l)): + n ^= bech32_mul(w[j], l[j][i]) + res.append(n) + return res + + +def ms32_recover(l): + return ms32_interpolate(l, 16) + + +# Copyright (c) 2023 Ben Westgate +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +def ms32_encode(hrp, data): + """ + Compute an MS32 string. + + :param hrp: Human-readable part of the ms32 string, usually 'ms'. + :param data: List of base32 integers representing data to encode. + :return: MS32 encoded string with the given HRP and data. + """ + combined = data + ms32_create_checksum(data) + return hrp + "1" + "".join([CHARSET[d] for d in combined]) + + +def ms32_decode(ms32_str): + """ + Validate an MS32 string and extract components. + + :param ms32_str: The MS32 encoded string to be validated. + :return: Tuple: HRP, k, ident, share index, data. If invalid, + (None, None, None, None, None) + """ + if ((any(ord(x) < 33 or ord(x) > 126 for x in ms32_str)) + or ms32_str.lower() != ms32_str and ms32_str.upper() != ms32_str): + return None, None, None, None, None + ms32_str = ms32_str.lower() + pos = ms32_str.rfind("1") + if pos < 1 or pos + 46 > len(ms32_str): + return None, None, None, None, None + if not all(x in CHARSET for x in ms32_str[pos + 1:]): + return None, None, None, None, None + hrp = ms32_str[:pos] + k = ms32_str[pos + 1] + ident = ms32_str[pos + 2: pos + 6] + share_index = ms32_str[pos + 6] + if not k.isdigit() or k == "0" and share_index != "s": + return None, None, None, None, None + data = [CHARSET.find(x) for x in ms32_str[pos + 1:]] + checksum_length = 13 if len(data) < 95 else 15 + if not ms32_verify_checksum(data): + return None, None, None, None, None + return hrp, k, ident, share_index, data[:-checksum_length] + + +def convertbits(data, frombits, tobits, pad=True): + """ + Perform general power-of-2 base conversion. + + :param data: List of integers to be converted. + :param frombits: Original base's bit size. + :param tobits: Target base's bit size. + :param pad: Whether to pad the result, defaults to True. + :return: List of integers in target base, or None on failure. + """ + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits: + return None + return ret + + +def decode(hrp, codex_str): + """ + Decode a codex32 string. + + :param hrp: Human-readable part of the codex32 string usually 'ms'. + :param codex_str: Codex32 string to be decoded. + :return: Tuple: k, ident, share index, decoded bytes. + If decoding fails, (None, None, None, None). + """ + hrpgot, k, ident, share_index, data = ms32_decode(codex_str) + if hrpgot != hrp: + return None, None, None, None + decoded = convertbits(data[6:], 5, 8, False) + if decoded is None or len(decoded) < 16 or len(decoded) > 65: + return None, None, None, None + return k, ident, share_index, bytes(decoded) + + +def ecc_padding(data): + """ + Calculate and return a byte with concatenated parity bits. + + :param data: Bytes of seed_len, hrp, k, ident, index and payload. + :return: Byte with concatenated parity in most significant bits. + """ + # Count mod 2 the number of set (1) bits in the byte data + parity = bin(int.from_bytes(data)).count('1') % 2 << 7 + # Count mod 2 the number of set (1) even bits in the byte data + parity += bin(int.from_bytes(data))[::2].count('1') % 2 << 6 + # Count mod 2 the number of set (1) odd bits in the byte data + parity += bin(int.from_bytes(data))[3::2].count('1') % 2 << 5 + # Count mod 2 the number of set (1) third bits in the byte data + parity += bin(int.from_bytes(data))[::3].count('1') % 2 << 4 + + return parity.to_bytes() + + +def encode(hrp, k, ident, share_index, payload): + """ + Encode a codex32 string. + + :param hrp: Human-readable part of the codex32 string. + :param k: Threshold parameter as a string. + :param ident: Identifier as a string. + :param share_index: Share index as a string. + :param payload: Payload data to be encoded. + :return: Codex32 string or None if validation fails. + """ + checksum = ecc_padding( + bytes(hrp + k + ident + share_index, "utf") + payload) + data = convertbits( + payload + checksum, 8, 5, False)[:len(convertbits(payload, 8, 5))] + ret = ms32_encode(hrp, [CHARSET.find(x.lower()) for x in + k + ident + share_index] + data) + if decode(hrp, ret) == (None, None, None, None): + return None + return ret + + +def validate_codex32_string_list(hrp, string_list, + k_must_equal_list_length=True): + """ + Validate uniform threshold, identifier, length, and unique indices. + + :param hrp: Human-readable prefix + :param string_list: List of codex32 strings to be validated. + :param k_must_equal_list_length: Flag for k must match list length. + :return: List of decoded data if valid, else None. + """ + list_len = len(string_list) + headers = set() + share_indices = set() + lengths = set() + + for codex32_string in string_list: + headers.add(tuple(decode(hrp, codex32_string)[:2])) + share_indices.add(decode(hrp, codex32_string)[2]) + lengths.add(len(codex32_string)) + if len(headers) > 1 or len(lengths) > 1: + return None + + if (k_must_equal_list_length and int(headers.pop()[0]) != list_len + or len(share_indices) < list_len): + return None + + return [ms32_decode(codex32_string)[4] for codex32_string in string_list] + + +def recover_master_seed(hrp, share_list): + """ + Derive master seed from a list of threshold valid codex32 shares. + + :param hrp: Human-readable part of the shares. + :param share_list: List of codex32 shares to recover master seed. + :return: The master seed as bytes, or None if share set is invalid. + """ + ms32_share_list = validate_codex32_string_list(hrp, share_list) + if not ms32_share_list: + return None + return bytes(convertbits(ms32_recover(ms32_share_list)[6:], 5, 8, False)) + + +def derive_share(hrp, string_list, fresh_share_index="s"): + """ + Derive an additional share from a valid codex32 string set. + + :param hrp: Human-readable part of the strings. + :param string_list: List of codex32 strings to derive from. + :param fresh_share_index: New index character to derive share at. + :return: Derived codex32 share or None if derivation fails. + """ + ms32_share_index = CHARSET.find(fresh_share_index.lower()) + if ms32_share_index < 0: + return None + return ms32_encode(hrp, ms32_interpolate( + validate_codex32_string_list(hrp, string_list), ms32_share_index)) + + +def ms32_fingerprint(seed): + """ + Calculate and convert the BIP32 fingerprint of a seed to MS32. + + :param seed: The master seed used to derive the fingerprint. + :return: List of 4 base32 integers representing the fingerprint. + """ + return convertbits(BIP32Node.from_rootseed( + seed, xtype="standard").calc_fingerprint_of_this_node(), 8, 5)[:4] + + +def relabel_codex32_strings(hrp, string_list, new_k="", new_id=""): + """ + Change the k and ident on a list of codex32 strings. + + :param hrp: Human-readable part of the codex32 strings. + :param string_list: List of codex32 strings to be relabeled. + :param new_k: New threshold parameter as a string, if provided. + :param new_id: New identifier as a string, if provided. + :return: List of relabeled codex32 strings. + """ + new_strings = [] + for codex32_string in string_list: + k, ident, share_index, decoded = decode(hrp, codex32_string) + new_k = k if not new_k else new_k + new_id = ident if not new_id else new_id + new_strings.append(encode(hrp, new_k, new_id, share_index, decoded)) + return new_strings + + +def shuffle_indices(index_seed, indices): + """ + Shuffle indices deterministically using provided key with ChaCha20. + + :param index_seed: The ChaCha20 key for deterministic shuffling. + :param indices: Characters to be shuffled as a string. + :return: List of shuffled characters sorted by assigned values. + """ + algorithm = algorithms.ChaCha20(index_seed, bytes(16)) + keystream = Cipher(algorithm, mode=None).encryptor() + counter = 0 # Counter to track current position in the keystream. + value = b"" # Storage for the assigned random byte. + block = b"" # Holds the latest keystream block. + assigned_values = {} # Dictionary to store chars and their values. + for char in indices: + # Ensure new random value is generated if there is a collision. + while value in assigned_values.values() or not value: + if not counter % 64: # Get new 64-byte block per 64 count. + block = keystream.update(bytes(64)) # ChaCha20 block. + value = block[counter % 64:counter % 64 + 1] # Rand byte. + counter += 1 + assigned_values[char] = value + return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) + + +def generate_shares(master_key="", user_entropy="", n=31, k="2", ident="NOID", + seed_length=16, existing_codex32_strings=None): + """ + Generate new codex32 shares from provided or derived entropy. + + :param master_key: BIP32 extended private master key from bitcoind. + :param user_entropy: User-provided entropy for improved security. + :param n: Total number of codex32 shares to generate (default: 31). + :param k: Threshold parameter (default: 2). + :param ident: Identifier (4 bech32 characters) or 'NOID' (default). + :param seed_length: Length of seed (16 to 64 bytes, default: 16). + :param existing_codex32_strings: List of existing codex32 strings. + :return: Tuple: master_seed (bytes), list of new codex32 shares. + """ + master_seed = b"" + if existing_codex32_strings is None: + existing_codex32_strings = [] + new_shares = [] + num_strings = len(existing_codex32_strings) + if (not validate_codex32_string_list('ms', existing_codex32_strings, False) + and existing_codex32_strings): + return None + available_indices = list(CHARSET) + for string in existing_codex32_strings: + k, ident, share_index, payload = decode("ms", string) + available_indices.remove(share_index) + if share_index == "s": + master_seed = payload + seed_length = len(payload) + + if num_strings == int(k) and not master_seed: + master_seed = recover_master_seed('ms', existing_codex32_strings) + if master_seed: + master_key = BIP32Node.from_rootseed(master_seed, xtype="standard") + elif master_key: + master_key = BIP32Node.from_xkey(master_key) + else: + return None + key_identifier = hash_160(master_key.eckey.get_public_key_bytes()) + entropy_header = (seed_length.to_bytes(length=1, byteorder="big") + + bytes("ms" + k + ident + "s", "utf") + key_identifier) + salt = entropy_header + bytes(CHARSET[n] + user_entropy, "utf") + # This is equivalent to hmac-sha512(b"Bitcoin seed", master_seed). + password = master_key.eckey.get_secret_bytes() + master_key.chaincode + # If scrypt absent visit OWASP Password Storage or use pbkdf2_hmac( + # 'sha512', password, salt, iterations=210_000 * 64, dklen=128) + derived_key = hashlib.scrypt( + password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=128) + index_seed = hmac.digest(derived_key, b"Index seed", "sha512")[:32] + available_indices.remove("s") + available_indices = shuffle_indices(index_seed, available_indices) + tmp_id = "temp" if ident == "NOID" else ident + + # Generate new shares, if necessary, to reach a threshold. + for i in range(num_strings, int(k)): + share_index = available_indices.pop() + info = bytes("Share payload with index: " + share_index, "utf") + payload = hmac.digest(derived_key, info, "sha512")[:seed_length] + new_shares.append(encode("ms", k, tmp_id, share_index, payload)) + existing_codex32_strings.extend(new_shares) + # Relabel existing codex32 strings, if necessary, with default ID. + if tmp_id == "temp": + master_seed = recover_master_seed('ms', existing_codex32_strings) + ident = "".join([CHARSET[d] for d in ms32_fingerprint(master_seed)]) + existing_codex32_strings = relabel_codex32_strings( + "ms", existing_codex32_strings, k, ident) + # Derive new shares using ms32_interpolate. + for i in range(int(k), n): + fresh_share_index = available_indices.pop() + new_share = derive_share('ms', existing_codex32_strings, + fresh_share_index) + new_shares.append(new_share) + + return master_seed, new_shares + + +def ident_encryption_key(payload, k, unique_string=""): + """ + Generate an MS32 encryption key from unique string and header data. + + :param payload: Payload for getting the length component of header. + :param k: Threshold component of header for key generation. + :param unique_string: Optional unique string to avoid ident reuse. + :return: Four symbol MS32 Encryption key derived from parameters. + """ + password = bytes(unique_string, "utf") + salt = len(payload).to_bytes(1, "big") + bytes("ms1" + k, "utf") + return convertbits(hashlib.scrypt( + password, salt=salt, n=2 ** 20, r=8, p=1, maxmem=1025 ** 3, dklen=3), + 8, 5, pad=False) + + +def encrypt_fingerprint(master_seed, k, unique_string=""): + """ + Encrypt the MS32 fingerprint using a unique string and header data. + + :param master_seed: The master seed used for fingerprint. + :param k: The threshold parameter as a string. + :param unique_string: Optional unique string encryption password. + :return: Encrypted fingerprint as a bech32 string. + """ + enc_key = ident_encryption_key(master_seed, k, unique_string) + new_id = [x ^ y for x, y in zip(ms32_fingerprint(master_seed), enc_key)] + return "".join([CHARSET[d] for d in new_id]) + + +def regenerate_shares(existing_codex32_strings, unique_string, + monotonic_counter, n=31, new_id=""): + """ + Regenerate fresh shares for an existing master seed & update ident. + + :param existing_codex32_strings: List of codex32 strings to reuse. + :param unique_string: Unique string for entropy. + :param monotonic_counter: Hardware or app monotonic counter value. + :param n: Number of shares to generate, default is 31. + :param new_id: New identifier, if provided. + :return: List of regenerated codex32 shares. + """ + master_seed, new_shares = generate_shares( + user_entropy=unique_string + f"{monotonic_counter:016x}", n=n, + existing_codex32_strings=existing_codex32_strings) + k, ident, _, _ = decode("ms", new_shares[0]) + if not new_id or new_id != ident: + new_id = encrypt_fingerprint(master_seed, k, unique_string) + return relabel_codex32_strings("ms", new_shares, new_id=new_id) + + +def decrypt_ident(codex32_string, unique_string=""): + """ + Decrypt a codex32 string identifier ciphertext using unique string. + + :param codex32_string: Codex32 string with an encrypted identifier. + :param unique_string: Optional unique string encryption password. + :return: Tuple with decrypted identifier (hex and MS32 string). + """ + k, ident, _, data = decode("ms", codex32_string) + enc_key = ident_encryption_key(data, k, unique_string) + ciphertext = [CHARSET.find(x) for x in ident] + plaintext = [x ^ y for x, y in zip(ciphertext, enc_key)] + return bytes(convertbits(plaintext, 5, 8)).hex()[:5], "".join( + [CHARSET[d] for d in plaintext]) + + +def shuffle_indexes(index_seed, indices=CHARSET.replace("s", "")): + """Shuffle indices deterministically with index_seed: HMAC-SHA256. + + Args: + index_seed (bytes): The seed used for deterministic shuffling. + indices (str): Characters to be shuffled. + + Returns: + list: Shuffled characters sorted based on assigned values. + + Provided only as a reference in case ChaCha20 is unavailable. + """ + from hashlib import sha256 + + counter = 0 # Counter to track current position in the keystream. + digest = b"" # Storage for HMAC-SHA256 digest + value = b"" # Storage for the assigned random value + assigned_values = {} # Dictionary to store characters and values. + for char in indices: + # Generates a new random value when there's a collision. + while value in assigned_values.values() or not value: + if not counter % 32: # Generate new digest every 32 bytes. + digest = hmac.digest( + index_seed, (counter // 32).to_bytes(8, "big"), sha256) + value = digest[counter % 32: counter % 32 + 1] # rand byte + counter += 1 + assigned_values[char] = value + return sorted(assigned_values.keys(), key=lambda x: assigned_values[x]) + + +def kdf_share(passphrase, codex32_str): + """ + Derive codex32 share from a passphrase and the codex32 header. + + Args: + passphrase: a seed backup passphrase as a string + codex32_str: a valid codex32 string to derive kdf share with. + + Returns: + the string encoded kdf_share + + """ + k, ident, _, payload = decode("ms", codex32_str) + password = bytes(passphrase, "utf") + salt = len(payload).to_bytes(1, "big") + bytes(codex32_str[:8], "utf") + derived_key = hashlib.scrypt(password, salt=salt, n=2 ** 20, r=8, p=1, + maxmem=1025 ** 3, dklen=128) + passphrase_index_seed = hmac.digest( + derived_key, b"Passphrase share index seed", "sha512")[:32] + share_index = shuffle_indices( + passphrase_index_seed, list(CHARSET.replace("s", ""))).pop() + payload = hmac.digest(derived_key, b"Passphrase share payload with index " + + bytes(share_index, "utf"), "sha512")[:len(payload)] + return encode("ms", k, ident, share_index, payload) From 079f7686c342497b4a21ca551458f65c3bc507e4 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 28 Oct 2023 10:17:22 -0500 Subject: [PATCH 28/31] update .gitignore --- .gitignore | 2 ++ .idea/codeStyles/Project.xml | 1 + .idea/codeStyles/codeStyleConfig.xml | 2 +- .idea/codex32.iml | 6 ++++-- .idea/misc.xml | 4 ++-- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3988bbb..2a2bfef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ mathematical-companion/*.log mathematical-companion/*.out mathematical-companion/*.pdf mathematical-companion/*.toc +reference/python-codex32/src/__pycache__/codex32.cpython-310.pyc +__pycache__/codex32.cpython-310.pyc diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index e62bc98..c6d698d 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,6 +1,7 @@