diff --git a/REFERENCE/Screenshot 2026-02-04 at 13.11.17.png b/REFERENCE/Screenshot 2026-02-04 at 13.11.17.png new file mode 100644 index 0000000..8ca4636 Binary files /dev/null and b/REFERENCE/Screenshot 2026-02-04 at 13.11.17.png differ diff --git a/REFERENCE/Screenshot 2026-02-05 at 01.11.32.png b/REFERENCE/Screenshot 2026-02-05 at 01.11.32.png new file mode 100644 index 0000000..6f03454 Binary files /dev/null and b/REFERENCE/Screenshot 2026-02-05 at 01.11.32.png differ diff --git a/REFERENCE/Screenshot 2026-02-05 at 01.11.43.png b/REFERENCE/Screenshot 2026-02-05 at 01.11.43.png new file mode 100644 index 0000000..75e4cfb Binary files /dev/null and b/REFERENCE/Screenshot 2026-02-05 at 01.11.43.png differ diff --git a/REFERENCE/Screenshot 2026-02-05 at 01.11.52.png b/REFERENCE/Screenshot 2026-02-05 at 01.11.52.png new file mode 100644 index 0000000..a1a591a Binary files /dev/null and b/REFERENCE/Screenshot 2026-02-05 at 01.11.52.png differ diff --git a/REFERENCE/Screenshot 2026-02-06 at 01.42.45.png b/REFERENCE/Screenshot 2026-02-06 at 01.42.45.png new file mode 100644 index 0000000..911b92a Binary files /dev/null and b/REFERENCE/Screenshot 2026-02-06 at 01.42.45.png differ diff --git a/REFERENCE/Screenshot 2026-02-06 at 23.49.37.png b/REFERENCE/Screenshot 2026-02-06 at 23.49.37.png new file mode 100644 index 0000000..bd36144 Binary files /dev/null and b/REFERENCE/Screenshot 2026-02-06 at 23.49.37.png differ diff --git a/REFERENCE/Screenshot 2026-02-07 at 02.26.22.png b/REFERENCE/Screenshot 2026-02-07 at 02.26.22.png new file mode 100644 index 0000000..78bd579 Binary files /dev/null and b/REFERENCE/Screenshot 2026-02-07 at 02.26.22.png differ diff --git a/REFERENCE/baseconv(2).py b/REFERENCE/baseconv(2).py new file mode 100644 index 0000000..38faeeb --- /dev/null +++ b/REFERENCE/baseconv(2).py @@ -0,0 +1,192 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# 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. +from binascii import a2b_base64, b2a_base64 + + +def base_decode(v, base): + """Abstraction to decode the str data in v as base; returns bytes""" + if not isinstance(v, str): + raise TypeError("Invalid value, expected str") + + if v == "": + return b"" + + # Base32 and Base43 are implemented custom in MaixPy on k210, else in python for simulator + # Base58 is implemented in pure_python_base_decode() below + # Base64 is a special case: We just use binascii's implementation without + # performing bitcoin-specific padding logic + if base == 32: + import base32 + + return base32.decode(v) + if base == 43: + import base43 + + return base43.decode(v) + if base == 58: + return pure_python_base_decode(v, 58) + if base == 64: + return a2b_base64(v) + + raise ValueError("not supported base: {}".format(base)) + + +def base_encode(v, base): + """Abstraction to encode the bytes data in v as base; returns str""" + if not isinstance(v, bytes): + raise TypeError("Invalid value, expected bytes") + + if v == b"": + return "" + + # Base32 and Base43 are implemented custom in MaixPy on k210, else in python for simulator + # Base58 is implemented in pure_python_base_encode() below + # Base64 is a special case: We just use binascii's implementation without + # performing bitcoin-specific padding logic. b2a_base64 always adds a \n + # char at the end which we strip before returning + if base == 32: + import base32 + + return base32.encode(v, False) + if base == 43: + import base43 + + return base43.encode(v, False) + if base == 58: + return pure_python_base_encode(v, 58) + if base == 64: + return b2a_base64(v).rstrip().decode() + + raise ValueError("not supported base: {}".format(base)) + + +def hint_encodings(str_data): + """NON-VERIFIED encoding hints of what input string might be, returns list""" + + if not isinstance(str_data, str): + raise TypeError("hint_encodings() expected str") + + encodings = [] + + # get min and max characters (sorted by ordinal value), + # check most restrictive encodings first + # is not strict -- does not try to decode -- assumptions are made + + min_chr = min(str_data) + max_chr = max(str_data) + + # might it be hex + if len(str_data) % 2 == 0 and "0" <= min_chr: + if max_chr <= "F": + encodings.append("HEX") + elif max_chr <= "f": + encodings.append("hex") + + # might it be base32 + if "2" <= min_chr and max_chr <= "Z": + encodings.append(32) + + # might it be base43 + if "$" <= min_chr and max_chr <= "Z": + encodings.append(43) + + # might it be base58? currently unused + # if "1" <= min_chr and max_chr <= "z": + # encodings.append(58) + + # might it be base64 + if "+" <= min_chr and max_chr <= "z": + encodings.append(64) + + # might it be ascii + if ord(max_chr) <= 127: + encodings.append("ascii") + + # might it be latin-1 or utf8 + if 128 <= ord(max_chr) <= 255: + encodings.append("latin-1") + else: + encodings.append("utf8") + + return encodings + + +# pure-python encoder/decoder for base43 and base58 below +B43CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:" +B58CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +def pure_python_base_decode(v, base): + """decode str v from base encoding; returns bytes""" + chars = B58CHARS if base == 58 else B43CHARS + long_value = 0 + power_of_base = 1 + for char in reversed(v): + digit = chars.find(char) + if digit == -1: + raise ValueError("forbidden character {} for base {}".format(char, base)) + long_value += digit * power_of_base + power_of_base *= base + result = bytearray() + while long_value >= 256: + div, mod = divmod(long_value, 256) + result.append(mod) + long_value = div + if long_value > 0: + result.append(long_value) + n_pad = 0 + for char in v: + if char == chars[0]: + n_pad += 1 + else: + break + if n_pad > 0: + result.extend(b"\x00" * n_pad) + return bytes(reversed(result)) + + +def pure_python_base_encode(v, base): + """decode bytes v from base encoding; returns str""" + chars = B58CHARS if base == 58 else B43CHARS + long_value = 0 + power_of_base = 1 + for char in reversed(v): + long_value += power_of_base * char + power_of_base <<= 8 + result = bytearray() + while long_value >= base: + div, mod = divmod(long_value, base) + result.extend(chars[mod].encode()) + long_value = div + if long_value > 0: + result.extend(chars[long_value].encode()) + # Bitcoin does a little leading-zero-compression: + # leading 0-bytes in the input become leading-1s + n_pad = 0 + for char in v: + if char == 0x00: + n_pad += 1 + else: + break + if n_pad > 0: + result.extend((chars[0] * n_pad).encode()) + return bytes(reversed(result)).decode() diff --git a/REFERENCE/baseconv.py b/REFERENCE/baseconv.py new file mode 100644 index 0000000..804ae10 --- /dev/null +++ b/REFERENCE/baseconv.py @@ -0,0 +1,65 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2025 Krux contributors + +# 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. + +import sys +import base64 +from krux import baseconv + +class base43: + + def encode(data, add_padding=False): + """Encodes data to Base43.""" + return baseconv.pure_python_base_encode(data, 43) + + def decode(encoded_str): + """Decodes a Base43 string.""" + return baseconv.pure_python_base_decode(encoded_str, 43) + + +class base32: + """ + Mock for the base32 module. + """ + def encode(data, add_padding=False): + """Encodes data to Base32.""" + encoded = base64.b32encode(data).decode('utf-8') + if not add_padding: + encoded = encoded.rstrip('=') + return encoded + + def decode(encoded_str): + """Decodes a Base32 string.""" + try: + len_pad = (8 - len(encoded_str) % 8) % 8 + decoded = base64.b32decode(encoded_str + ("=" * len_pad)) + except ValueError as e: + raise ValueError("Invalid Base32 string: %s" % e) + + return decoded + + + +if "base32" not in sys.modules: + sys.modules["base32"] = base32 + +if "base43" not in sys.modules: + sys.modules["base43"] = base43 diff --git a/REFERENCE/bun-CompactseedQR-implement.md.rtf b/REFERENCE/bun-CompactseedQR-implement.md.rtf new file mode 100644 index 0000000..2bce61d --- /dev/null +++ b/REFERENCE/bun-CompactseedQR-implement.md.rtf @@ -0,0 +1,692 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2867 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\froman\fcharset0 Times-Roman;\f1\froman\fcharset0 Times-Bold;\f2\froman\fcharset0 TimesNewRomanPSMT; +\f3\fmodern\fcharset0 Courier-Bold;\f4\fnil\fcharset0 HelveticaNeue;\f5\fmodern\fcharset0 Courier; +\f6\fmodern\fcharset0 Courier-Oblique;\f7\fnil\fcharset0 Menlo-Italic;\f8\fnil\fcharset0 AppleColorEmoji; +\f9\fnil\fcharset0 Menlo-Regular;} +{\colortbl;\red255\green255\blue255;\red255\green255\blue255;\red0\green0\blue0;\red185\green188\blue186; +\red162\green127\blue173;\red166\green178\blue85;\red111\green144\blue176;\red212\green128\blue77;\red132\green134\blue132; +\red0\green0\blue0;\red0\green0\blue233;} +{\*\expandedcolortbl;;\cssrgb\c100000\c100000\c100000;\cssrgb\c0\c0\c0\c84706;\cssrgb\c77255\c78431\c77647; +\cssrgb\c69804\c58039\c73333;\cssrgb\c70980\c74118\c40784;\cssrgb\c50588\c63529\c74510;\cssrgb\c87059\c57647\c37255;\cssrgb\c58824\c59608\c58824; +\cssrgb\c0\c0\c0;\cssrgb\c0\c0\c93333;} +{\*\listtable{\list\listtemplateid1\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid1\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listname ;}\listid1}} +{\*\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}} +\paperw11900\paperh16840\margl1440\margr1440\vieww11520\viewh8400\viewkind0 +\deftab720 +\pard\pardeftab720\sa240\partightenfactor0 + +\f0\fs24 \cf2 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 You're right to clarify! The test code I provided +\f1\b only covers reading/decoding CompactSeedQR +\f0\b0 (direction 2). Let me provide a +\f1\b complete bidirectional test +\f0\b0 that covers both:\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\sa240\partightenfactor0 +\ls1\ilvl0 +\f1\b \cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 Encoding +\f0\b0 : Mnemonic +\f2 \uc0\u8594 +\f0 CompactSeedQR (binary data that goes into QR code)\ +\ls1\ilvl0 +\f1\b \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 Decoding +\f0\b0 : CompactSeedQR +\f2 \uc0\u8594 +\f0 Mnemonic (restore from QR)\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Complete Bidirectional Test ( +\f3\fs39 compact-seedqr-complete.ts +\f1\fs36 )\ +\pard\pardeftab720\qc\partightenfactor0 + +\f4\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f5\fs26 \cf2 \strokec2 bash\ +\pard\pardeftab720\partightenfactor0 +\cf4 \strokec4 bun add bip39 qrcode\ +\pard\pardeftab720\qc\partightenfactor0 + +\f4\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f5\fs26 \cf2 \strokec2 typescript\ +\pard\pardeftab720\partightenfactor0 + +\f3\b \cf5 \strokec5 import +\f5\b0 \cf4 \strokec4 * +\f3\b \cf5 \strokec5 as +\f5\b0 \cf4 \strokec4 bip39 +\f3\b \cf5 \strokec5 from +\f5\b0 \cf4 \strokec4 \cf6 \strokec6 "bip39"\cf4 \strokec4 ;\ + +\f3\b \cf5 \strokec5 import +\f5\b0 \cf4 \strokec4 QRCode +\f3\b \cf5 \strokec5 from +\f5\b0 \cf4 \strokec4 \cf6 \strokec6 "qrcode"\cf4 \strokec4 ;\ +\ + +\f3\b \cf5 \strokec5 interface +\f5\b0 \cf4 \strokec4 \cf7 \strokec7 CompactSeedQRTestVector\cf4 \strokec4 \{\ + name: \cf8 \strokec8 string\cf4 \strokec4 ;\ + mnemonic: \cf8 \strokec8 string\cf4 \strokec4 ;\ + hexEntropy: \cf8 \strokec8 string\cf4 \strokec4 ;\ + wordCount: \cf8 \strokec8 12\cf4 \strokec4 | \cf8 \strokec8 24\cf4 \strokec4 ;\ +\}\ +\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 TEST_VECTORS: CompactSeedQRTestVector[] = [\ + \{\ + name: \cf6 \strokec6 "Test Vector 1: 24-word"\cf4 \strokec4 ,\ + mnemonic: \cf6 \strokec6 "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire"\cf4 \strokec4 ,\ + hexEntropy: \cf6 \strokec6 "0e74b64107f94cc0ccfae6a13dcbec3662154fec67e0e00999c07892597d190a"\cf4 \strokec4 ,\ + wordCount: \cf8 \strokec8 24\cf4 \strokec4 \ + \},\ + \{\ + name: \cf6 \strokec6 "Test Vector 4: 12-word"\cf4 \strokec4 ,\ + mnemonic: \cf6 \strokec6 "forum undo fragile fade shy sign arrest garment culture tube off merit"\cf4 \strokec4 ,\ + hexEntropy: \cf6 \strokec6 "5bbd9d71a8ec799083laff359d456545"\cf4 \strokec4 ,\ + wordCount: \cf8 \strokec8 12\cf4 \strokec4 \ + \},\ + \{\ + name: \cf6 \strokec6 "Test Vector 6: 12-word (with null byte)"\cf4 \strokec4 ,\ + mnemonic: \cf6 \strokec6 "approve fruit lens brass ring actual stool coin doll boss strong rate"\cf4 \strokec4 ,\ + hexEntropy: \cf6 \strokec6 "0acbba008d9ba005f5996b40a3475cd9"\cf4 \strokec4 ,\ + wordCount: \cf8 \strokec8 12\cf4 \strokec4 \ + \}\ +];\ +\ +\pard\pardeftab720\partightenfactor0 + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // DIRECTION 1: ENCODE - Mnemonic +\f7 \uc0\u8594 +\f6 CompactSeedQR (Binary for QR Code) +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ +\ + +\f6\i \cf9 \strokec9 /**\ + * Convert mnemonic to CompactSeedQR format (raw entropy bytes)\ + * This is what you encode into the QR code as BINARY data\ + */ +\f5\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f3\b \cf5 \strokec5 function +\f5\b0 \cf4 \strokec4 encodeToCompactSeedQR(mnemonic: \cf8 \strokec8 string\cf4 \strokec4 ): Buffer \{\ + +\f6\i \cf9 \strokec9 // Validate mnemonic +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 if +\f5\b0 \cf4 \strokec4 (!bip39.validateMnemonic(mnemonic)) \{\ + +\f3\b \cf5 \strokec5 throw +\f5\b0 \cf4 \strokec4 +\f3\b \cf5 \strokec5 new +\f5\b0 \cf4 \strokec4 \cf7 \strokec7 Error\cf4 \strokec4 (\cf6 \strokec6 "Invalid mnemonic"\cf4 \strokec4 );\ + \}\ +\ + +\f6\i \cf9 \strokec9 // Extract entropy (WITHOUT checksum) +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // bip39.mnemonicToEntropy returns hex string +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 entropyHex = bip39.mnemonicToEntropy(mnemonic);\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 entropy = Buffer.from(entropyHex, \cf6 \strokec6 "hex"\cf4 \strokec4 );\ +\ + +\f6\i \cf9 \strokec9 // Validate length +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 if +\f5\b0 \cf4 \strokec4 (entropy.length !== \cf8 \strokec8 16\cf4 \strokec4 && entropy.length !== \cf8 \strokec8 32\cf4 \strokec4 ) \{\ + +\f3\b \cf5 \strokec5 throw +\f5\b0 \cf4 \strokec4 +\f3\b \cf5 \strokec5 new +\f5\b0 \cf4 \strokec4 \cf7 \strokec7 Error\cf4 \strokec4 (\cf6 \strokec6 `Invalid entropy length: \cf4 \strokec4 $\{entropy.length\}\cf6 \strokec6 `\cf4 \strokec4 );\ + \}\ +\ + +\f3\b \cf5 \strokec5 return +\f5\b0 \cf4 \strokec4 entropy;\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f6\i \cf9 \strokec9 /**\ + * Generate a QR code from mnemonic (as PNG data URL)\ + */ +\f5\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f3\b \cf5 \strokec5 async +\f5\b0 \cf4 \strokec4 +\f3\b \cf5 \strokec5 function +\f5\b0 \cf4 \strokec4 generateCompactSeedQR(mnemonic: \cf8 \strokec8 string\cf4 \strokec4 ): \cf8 \strokec8 Promise\cf4 \strokec4 <\cf8 \strokec8 string\cf4 \strokec4 > \{\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 entropy = encodeToCompactSeedQR(mnemonic);\ + \ + +\f6\i \cf9 \strokec9 // Generate QR code with BINARY data +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // Important: Use 'byte' mode for raw binary data +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 qrDataURL = +\f3\b \cf5 \strokec5 await +\f5\b0 \cf4 \strokec4 QRCode.toDataURL([\{ data: entropy, mode: \cf6 \strokec6 'byte'\cf4 \strokec4 \}], \{\ + errorCorrectionLevel: \cf6 \strokec6 'L'\cf4 \strokec4 , +\f6\i \cf9 \strokec9 // SeedSigner uses Low error correction +\f5\i0 \cf4 \strokec4 \ + type: \cf6 \strokec6 'image/png'\cf4 \strokec4 ,\ + width: \cf8 \strokec8 300\cf4 \strokec4 \ + \});\ +\ + +\f3\b \cf5 \strokec5 return +\f5\b0 \cf4 \strokec4 qrDataURL;\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // DIRECTION 2: DECODE - CompactSeedQR +\f7 \uc0\u8594 +\f6 Mnemonic (Restore from QR) +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ +\ + +\f6\i \cf9 \strokec9 /**\ + * Parse CompactSeedQR from raw bytes (what QR scanner gives you)\ + */ +\f5\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f3\b \cf5 \strokec5 function +\f5\b0 \cf4 \strokec4 decodeCompactSeedQR(data: Buffer | Uint8Array): \cf8 \strokec8 string\cf4 \strokec4 \{\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 entropy = Buffer.from(data);\ +\ + +\f6\i \cf9 \strokec9 // Validate length +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 if +\f5\b0 \cf4 \strokec4 (entropy.length !== \cf8 \strokec8 16\cf4 \strokec4 && entropy.length !== \cf8 \strokec8 32\cf4 \strokec4 ) \{\ + +\f3\b \cf5 \strokec5 throw +\f5\b0 \cf4 \strokec4 +\f3\b \cf5 \strokec5 new +\f5\b0 \cf4 \strokec4 \cf7 \strokec7 Error\cf4 \strokec4 (\ + \cf6 \strokec6 `Invalid CompactSeedQR length: \cf4 \strokec4 $\{entropy.length\}\cf6 \strokec6 bytes. Must be 16 (12-word) or 32 (24-word).`\cf4 \strokec4 \ + );\ + \}\ +\ + +\f6\i \cf9 \strokec9 // Convert entropy to mnemonic (automatically adds BIP39 checksum) +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 mnemonic = bip39.entropyToMnemonic(entropy);\ +\ + +\f3\b \cf5 \strokec5 return +\f5\b0 \cf4 \strokec4 mnemonic;\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f6\i \cf9 \strokec9 /**\ + * Parse from hex string (for testing)\ + */ +\f5\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f3\b \cf5 \strokec5 function +\f5\b0 \cf4 \strokec4 decodeCompactSeedQRFromHex(hexEntropy: \cf8 \strokec8 string\cf4 \strokec4 ): \cf8 \strokec8 string\cf4 \strokec4 \{\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 entropy = Buffer.from(hexEntropy, \cf6 \strokec6 "hex"\cf4 \strokec4 );\ + +\f3\b \cf5 \strokec5 return +\f5\b0 \cf4 \strokec4 decodeCompactSeedQR(entropy);\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // TESTS +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f3\b \cf5 \strokec5 async +\f5\b0 \cf4 \strokec4 +\f3\b \cf5 \strokec5 function +\f5\b0 \cf4 \strokec4 runBidirectionalTests() \{\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 " +\f8 \uc0\u55358 \u56810 +\f5 CompactSeedQR Bidirectional Tests\\n"\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 "="\cf4 \strokec4 .repeat(\cf8 \strokec8 80\cf4 \strokec4 ));\ +\ + +\f3\b \cf5 \strokec5 let +\f5\b0 \cf4 \strokec4 passed = \cf8 \strokec8 0\cf4 \strokec4 ;\ + +\f3\b \cf5 \strokec5 let +\f5\b0 \cf4 \strokec4 failed = \cf8 \strokec8 0\cf4 \strokec4 ;\ +\ + +\f3\b \cf5 \strokec5 for +\f5\b0 \cf4 \strokec4 ( +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 vector +\f3\b \cf5 \strokec5 of +\f5\b0 \cf4 \strokec4 TEST_VECTORS) \{\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 `\\n +\f8 \uc0\u55357 \u56523 +\f5 \cf4 \strokec4 $\{vector.name\}\cf6 \strokec6 `\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 `Mnemonic: \cf4 \strokec4 $\{vector.mnemonic.slice(\cf8 \strokec8 0\cf4 \strokec4 , \cf8 \strokec8 50\cf4 \strokec4 )\}\cf6 \strokec6 ...`\cf4 \strokec4 );\ +\ + +\f3\b \cf5 \strokec5 try +\f5\b0 \cf4 \strokec4 \{\ + +\f6\i \cf9 \strokec9 // ===== DIRECTION 1: ENCODE ===== +\f5\i0 \cf4 \strokec4 \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 "\\n +\f8 \uc0\u55357 \u56594 +\f5 ENCODE: Mnemonic +\f9 \uc0\u8594 +\f5 CompactSeedQR"\cf4 \strokec4 );\ + \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 encodedEntropy = encodeToCompactSeedQR(vector.mnemonic);\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 encodedHex = encodedEntropy.toString(\cf6 \strokec6 "hex"\cf4 \strokec4 );\ + \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Generated entropy: \cf4 \strokec4 $\{encodedHex\}\cf6 \strokec6 `\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Expected entropy: \cf4 \strokec4 $\{vector.hexEntropy\}\cf6 \strokec6 `\cf4 \strokec4 );\ + \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 encodeMatches = encodedHex === vector.hexEntropy;\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` \cf4 \strokec4 $\{encodeMatches ? \cf6 \strokec6 " +\f8 \uc0\u9989 +\f5 "\cf4 \strokec4 : \cf6 \strokec6 " +\f8 \uc0\u10060 +\f5 "\cf4 \strokec4 \}\cf6 \strokec6 Encode \cf4 \strokec4 $\{encodeMatches ? \cf6 \strokec6 "PASSED"\cf4 \strokec4 : \cf6 \strokec6 "FAILED"\cf4 \strokec4 \}\cf6 \strokec6 `\cf4 \strokec4 );\ +\ + +\f6\i \cf9 \strokec9 // Generate actual QR code +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 qrDataURL = +\f3\b \cf5 \strokec5 await +\f5\b0 \cf4 \strokec4 generateCompactSeedQR(vector.mnemonic);\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` +\f8 \uc0\u55357 \u56561 +\f5 QR Code generated (\cf4 \strokec4 $\{qrDataURL.length\}\cf6 \strokec6 bytes PNG data)`\cf4 \strokec4 );\ +\ + +\f6\i \cf9 \strokec9 // ===== DIRECTION 2: DECODE ===== +\f5\i0 \cf4 \strokec4 \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 "\\n +\f8 \uc0\u55357 \u56595 +\f5 DECODE: CompactSeedQR +\f9 \uc0\u8594 +\f5 Mnemonic"\cf4 \strokec4 );\ + \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 decodedMnemonic = decodeCompactSeedQRFromHex(vector.hexEntropy);\ + \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Decoded: \cf4 \strokec4 $\{decodedMnemonic.slice(\cf8 \strokec8 0\cf4 \strokec4 , \cf8 \strokec8 50\cf4 \strokec4 )\}\cf6 \strokec6 ...`\cf4 \strokec4 );\ + \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 decodeMatches = decodedMnemonic === vector.mnemonic;\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` \cf4 \strokec4 $\{decodeMatches ? \cf6 \strokec6 " +\f8 \uc0\u9989 +\f5 "\cf4 \strokec4 : \cf6 \strokec6 " +\f8 \uc0\u10060 +\f5 "\cf4 \strokec4 \}\cf6 \strokec6 Decode \cf4 \strokec4 $\{decodeMatches ? \cf6 \strokec6 "PASSED"\cf4 \strokec4 : \cf6 \strokec6 "FAILED"\cf4 \strokec4 \}\cf6 \strokec6 `\cf4 \strokec4 );\ +\ + +\f6\i \cf9 \strokec9 // ===== ROUND-TRIP TEST ===== +\f5\i0 \cf4 \strokec4 \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 "\\n +\f8 \uc0\u55357 \u56580 +\f5 ROUND-TRIP: Mnemonic +\f9 \uc0\u8594 +\f5 QR +\f9 \uc0\u8594 +\f5 Mnemonic"\cf4 \strokec4 );\ + \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 roundTripMnemonic = decodeCompactSeedQR(encodedEntropy);\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 roundTripMatches = roundTripMnemonic === vector.mnemonic;\ + \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` \cf4 \strokec4 $\{roundTripMatches ? \cf6 \strokec6 " +\f8 \uc0\u9989 +\f5 "\cf4 \strokec4 : \cf6 \strokec6 " +\f8 \uc0\u10060 +\f5 "\cf4 \strokec4 \}\cf6 \strokec6 Round-trip \cf4 \strokec4 $\{roundTripMatches ? \cf6 \strokec6 "PASSED"\cf4 \strokec4 : \cf6 \strokec6 "FAILED"\cf4 \strokec4 \}\cf6 \strokec6 `\cf4 \strokec4 );\ +\ + +\f3\b \cf5 \strokec5 if +\f5\b0 \cf4 \strokec4 (encodeMatches && decodeMatches && roundTripMatches) \{\ + passed++;\ + \} +\f3\b \cf5 \strokec5 else +\f5\b0 \cf4 \strokec4 \{\ + failed++;\ + \}\ +\ + \} +\f3\b \cf5 \strokec5 catch +\f5\b0 \cf4 \strokec4 (error) \{\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` +\f8 \uc0\u10060 +\f5 ERROR: \cf4 \strokec4 $\{error\}\cf6 \strokec6 `\cf4 \strokec4 );\ + failed++;\ + \}\ + \}\ +\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 "\\n"\cf4 \strokec4 + \cf6 \strokec6 "="\cf4 \strokec4 .repeat(\cf8 \strokec8 80\cf4 \strokec4 ));\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 `\\n +\f8 \uc0\u55357 \u56522 +\f5 Results: \cf4 \strokec4 $\{passed\}\cf6 \strokec6 /\cf4 \strokec4 $\{TEST_VECTORS.length\}\cf6 \strokec6 passed`\cf4 \strokec4 );\ + \ + +\f3\b \cf5 \strokec5 if +\f5\b0 \cf4 \strokec4 (failed === \cf8 \strokec8 0\cf4 \strokec4 ) \{\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 " +\f8 \uc0\u55356 \u57225 +\f5 ALL TESTS PASSED!"\cf4 \strokec4 );\ + \}\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // EXAMPLE USAGE +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // ============================================================================ +\f5\i0 \cf4 \strokec4 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f3\b \cf5 \strokec5 async +\f5\b0 \cf4 \strokec4 +\f3\b \cf5 \strokec5 function +\f5\b0 \cf4 \strokec4 demonstrateUsage() \{\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 "\\n\\n"\cf4 \strokec4 + \cf6 \strokec6 "="\cf4 \strokec4 .repeat(\cf8 \strokec8 80\cf4 \strokec4 ));\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 " +\f8 \uc0\u55357 \u56481 +\f5 USAGE EXAMPLE\\n"\cf4 \strokec4 );\ +\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 exampleMnemonic = \cf6 \strokec6 "forum undo fragile fade shy sign arrest garment culture tube off merit"\cf4 \strokec4 ;\ +\ + +\f6\i \cf9 \strokec9 // ===== CREATE QR CODE ===== +\f5\i0 \cf4 \strokec4 \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 " +\f8 1\uc0\u65039 \u8419 +\f5 CREATE CompactSeedQR from mnemonic:"\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Input: \cf4 \strokec4 $\{exampleMnemonic\}\cf6 \strokec6 `\cf4 \strokec4 );\ + \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 entropy = encodeToCompactSeedQR(exampleMnemonic);\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Binary data (hex): \cf4 \strokec4 $\{entropy.toString(\cf6 \strokec6 "hex"\cf4 \strokec4 )\}\cf6 \strokec6 `\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Binary data (bytes): [\cf4 \strokec4 $\{\cf8 \strokec8 Array\cf4 \strokec4 .from(entropy).join(\cf6 \strokec6 ", "\cf4 \strokec4 )\}\cf6 \strokec6 ]`\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Length: \cf4 \strokec4 $\{entropy.length\}\cf6 \strokec6 bytes (\cf4 \strokec4 $\{entropy.length === \cf8 \strokec8 16\cf4 \strokec4 ? \cf6 \strokec6 "12-word"\cf4 \strokec4 : \cf6 \strokec6 "24-word"\cf4 \strokec4 \}\cf6 \strokec6 )`\cf4 \strokec4 );\ +\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 qrDataURL = +\f3\b \cf5 \strokec5 await +\f5\b0 \cf4 \strokec4 generateCompactSeedQR(exampleMnemonic);\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` +\f8 \uc0\u9989 +\f5 QR code generated!`\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` +\f8 \uc0\u55357 \u56561 +\f5 Display this QR: `\cf4 \strokec4 );\ +\ + +\f6\i \cf9 \strokec9 // ===== SCAN QR CODE ===== +\f5\i0 \cf4 \strokec4 \ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 "\\n +\f8 2\uc0\u65039 \u8419 +\f5 SCAN CompactSeedQR and restore mnemonic:"\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Simulating QR scanner output...`\cf4 \strokec4 );\ + \ + +\f6\i \cf9 \strokec9 // In real app, your QR scanner returns raw bytes: +\f5\i0 \cf4 \strokec4 \ + +\f6\i \cf9 \strokec9 // const scannedBytes = qrScanner.decode(); +\f5\i0 \cf4 \strokec4 \ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 scannedBytes = entropy; +\f6\i \cf9 \strokec9 // Simulate scanning the QR we just created +\f5\i0 \cf4 \strokec4 \ +\ + +\f3\b \cf5 \strokec5 const +\f5\b0 \cf4 \strokec4 restoredMnemonic = decodeCompactSeedQR(scannedBytes);\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` Restored: \cf4 \strokec4 $\{restoredMnemonic\}\cf6 \strokec6 `\cf4 \strokec4 );\ + \cf8 \strokec8 console\cf4 \strokec4 .log(\cf6 \strokec6 ` +\f8 \uc0\u9989 +\f5 Match: \cf4 \strokec4 $\{restoredMnemonic === exampleMnemonic\}\cf6 \strokec6 `\cf4 \strokec4 );\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f6\i \cf9 \strokec9 // Run everything +\f5\i0 \cf4 \strokec4 \ +runBidirectionalTests().then(() => demonstrateUsage());\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Run Command\ +\pard\pardeftab720\qc\partightenfactor0 + +\f4\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f5\fs26 \cf2 \strokec2 bash\ +\pard\pardeftab720\partightenfactor0 +\cf4 \strokec4 bun run compact-seedqr-complete.ts\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Expected Output\ +\pard\pardeftab720\qc\partightenfactor0 + +\f4\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f5\fs26 \cf2 \strokec2 text\ +\pard\pardeftab720\partightenfactor0 + +\f8 \cf4 \strokec4 \uc0\u55358 \u56810 +\f5 CompactSeedQR Bidirectional Tests\ +\ +================================================================================\ +\ + +\f8 \uc0\u55357 \u56523 +\f5 Test Vector 1: 24-word\ +Mnemonic: attack pizza motion avocado network gather crop fre...\ +\ + +\f8 \uc0\u55357 \u56594 +\f5 ENCODE: Mnemonic +\f9 \uc0\u8594 +\f5 CompactSeedQR\ + Generated entropy: 0e74b64107f94cc0ccfae6a13dcbec3662154fec67e0e00999c07892597d190a\ + Expected entropy: 0e74b64107f94cc0ccfae6a13dcbec3662154fec67e0e00999c07892597d190a\ + +\f8 \uc0\u9989 +\f5 Encode PASSED\ + +\f8 \uc0\u55357 \u56561 +\f5 QR Code generated (2847 bytes PNG data)\ +\ + +\f8 \uc0\u55357 \u56595 +\f5 DECODE: CompactSeedQR +\f9 \uc0\u8594 +\f5 Mnemonic\ + Decoded: attack pizza motion avocado network gather crop fre...\ + +\f8 \uc0\u9989 +\f5 Decode PASSED\ +\ + +\f8 \uc0\u55357 \u56580 +\f5 ROUND-TRIP: Mnemonic +\f9 \uc0\u8594 +\f5 QR +\f9 \uc0\u8594 +\f5 Mnemonic\ + +\f8 \uc0\u9989 +\f5 Round-trip PASSED\ +\ +...\ +\ +================================================================================\ +\ + +\f8 \uc0\u55357 \u56522 +\f5 Results: 3/3 passed\ + +\f8 \uc0\u55356 \u57225 +\f5 ALL TESTS PASSED!\ +\ +================================================================================\ + +\f8 \uc0\u55357 \u56481 +\f5 USAGE EXAMPLE\ +\ + +\f8 1\uc0\u65039 \u8419 +\f5 CREATE CompactSeedQR from mnemonic:\ + Input: forum undo fragile fade shy sign arrest garment culture tube off merit\ + Binary data (hex): 5bbd9d71a8ec799083laff359d456545\ + Binary data (bytes): [91, 189, 157, 113, 168, 236, 121, ...]\ + Length: 16 bytes (12-word)\ + +\f8 \uc0\u9989 +\f5 QR code generated!\ +\ + +\f8 2\uc0\u65039 \u8419 +\f5 SCAN CompactSeedQR and restore mnemonic:\ + Simulating QR scanner output...\ + Restored: forum undo fragile fade shy sign arrest garment culture tube off merit\ + +\f8 \uc0\u9989 +\f5 Match: true\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Summary: What the Code Does\ + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrt\brdrnil \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1000\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2160 +\clvertalc \clshdrawnil \clwWidth2033\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx4320 +\clvertalc \clshdrawnil \clwWidth2779\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx6480 +\clvertalc \clshdrawnil \clwWidth4979\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\qc\partightenfactor0 + +\fs24 \cf2 \strokec10 Direction\cf0 \cell +\pard\intbl\itap1\pardeftab720\qc\partightenfactor0 +\cf2 Input\cf0 \cell +\pard\intbl\itap1\pardeftab720\qc\partightenfactor0 +\cf2 Output\cf0 \cell +\pard\intbl\itap1\pardeftab720\qc\partightenfactor0 +\cf2 What It Does\cf0 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1000\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2160 +\clvertalc \clshdrawnil \clwWidth2033\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx4320 +\clvertalc \clshdrawnil \clwWidth2779\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx6480 +\clvertalc \clshdrawnil \clwWidth4979\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 1. Encode +\f0\b0 \cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Mnemonic (words)\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Binary entropy (16/32 bytes)\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Strips checksum, creates raw bytes for QR\cf0 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrt\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1000\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2160 +\clvertalc \clshdrawnil \clwWidth2033\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx4320 +\clvertalc \clshdrawnil \clwWidth2779\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx6480 +\clvertalc \clshdrawnil \clwWidth4979\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1\b \cf2 2. Decode +\f0\b0 \cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Binary data from QR\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Mnemonic (words)\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Reads raw bytes, adds checksum, converts to words\cf0 \cell \lastrow\row +\pard\pardeftab720\qc\partightenfactor0 + +\f4\fs22 \cf3 \strokec3 \ +\ +\pard\pardeftab720\sa240\partightenfactor0 + +\f0\fs24 \cf2 \strokec2 The QR code itself contains +\f1\b raw binary entropy +\f0\b0 (NOT encoded as hex or base64). This is why CompactSeedQR is smaller than Standard SeedQR!{\field{\*\fldinst{HYPERLINK "https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md"}}{\fldrslt \cf11 \ul \ulc11 \strokec11 github+1}}\ +} \ No newline at end of file diff --git a/REFERENCE/bun-seedQR-implement.md.rtf b/REFERENCE/bun-seedQR-implement.md.rtf new file mode 100644 index 0000000..d66c4e6 --- /dev/null +++ b/REFERENCE/bun-seedQR-implement.md.rtf @@ -0,0 +1,470 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2867 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\froman\fcharset0 Times-Roman;\f1\froman\fcharset0 Times-Bold;\f2\fnil\fcharset0 HelveticaNeue; +\f3\fmodern\fcharset0 Courier;\f4\fmodern\fcharset0 Courier-Bold;\f5\fmodern\fcharset0 Courier-Oblique; +\f6\fnil\fcharset0 AppleColorEmoji;} +{\colortbl;\red255\green255\blue255;\red255\green255\blue255;\red0\green0\blue0;\red185\green188\blue186; +\red111\green144\blue176;\red162\green127\blue173;\red166\green178\blue85;\red132\green134\blue132;\red212\green128\blue77; +\red0\green0\blue0;\red191\green80\blue83;} +{\*\expandedcolortbl;;\cssrgb\c100000\c100000\c100000;\cssrgb\c0\c0\c0\c84706;\cssrgb\c77255\c78431\c77647; +\cssrgb\c50588\c63529\c74510;\cssrgb\c69804\c58039\c73333;\cssrgb\c70980\c74118\c40784;\cssrgb\c58824\c59608\c58824;\cssrgb\c87059\c57647\c37255; +\cssrgb\c0\c0\c0;\cssrgb\c80000\c40000\c40000;} +\paperw11900\paperh16840\margl1440\margr1440\vieww11520\viewh8400\viewkind0 +\deftab720 +\pard\pardeftab720\sa240\partightenfactor0 + +\f0\fs24 \cf2 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 Here's a +\f1\b Bun TypeScript test +\f0\b0 for +\f1\b SeedSigner SeedQR +\f0\b0 (both Standard and Compact formats) using +\f1\b Test Vector 1 +\f0\b0 from the official specification:\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 Setup\ +\pard\pardeftab720\qc\partightenfactor0 + +\f2\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f3\fs26 \cf2 \strokec2 bash\ +\pard\pardeftab720\partightenfactor0 +\cf4 \strokec4 mkdir seedsigner-test\ +\pard\pardeftab720\partightenfactor0 +\cf5 \strokec5 cd\cf4 \strokec4 seedsigner-test\ +bun init -y\ +bun add bip39\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f4\b\fs39 \cf2 \strokec2 seedsigner-test.ts +\f1\fs36 \ +\pard\pardeftab720\qc\partightenfactor0 + +\f2\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f3\fs26 \cf2 \strokec2 typescript\ +\pard\pardeftab720\partightenfactor0 + +\f4\b \cf6 \strokec6 import +\f3\b0 \cf4 \strokec4 * +\f4\b \cf6 \strokec6 as +\f3\b0 \cf4 \strokec4 bip39 +\f4\b \cf6 \strokec6 from +\f3\b0 \cf4 \strokec4 \cf7 \strokec7 "bip39"\cf4 \strokec4 ;\ +\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 BIP39_ENGLISH_WORDLIST = [\ + \cf7 \strokec7 "abandon"\cf4 \strokec4 , \cf7 \strokec7 "ability"\cf4 \strokec4 , \cf7 \strokec7 "able"\cf4 \strokec4 , \cf7 \strokec7 "about"\cf4 \strokec4 , \cf7 \strokec7 "above"\cf4 \strokec4 , \cf7 \strokec7 "absent"\cf4 \strokec4 , \cf7 \strokec7 "absorb"\cf4 \strokec4 , \cf7 \strokec7 "abstract"\cf4 \strokec4 , \cf7 \strokec7 "absurd"\cf4 \strokec4 , \cf7 \strokec7 "abuse"\cf4 \strokec4 ,\ + \cf7 \strokec7 "access"\cf4 \strokec4 , \cf7 \strokec7 "accident"\cf4 \strokec4 , \cf7 \strokec7 "account"\cf4 \strokec4 , \cf7 \strokec7 "accuse"\cf4 \strokec4 , \cf7 \strokec7 "achieve"\cf4 \strokec4 , \cf7 \strokec7 "acid"\cf4 \strokec4 , \cf7 \strokec7 "acoustic"\cf4 \strokec4 , \cf7 \strokec7 "acquire"\cf4 \strokec4 , \cf7 \strokec7 "across"\cf4 \strokec4 , \cf7 \strokec7 "act"\cf4 \strokec4 ,\ + \cf7 \strokec7 "actor"\cf4 \strokec4 , \cf7 \strokec7 "actress"\cf4 \strokec4 , \cf7 \strokec7 "actual"\cf4 \strokec4 , \cf7 \strokec7 "adapt"\cf4 \strokec4 , \cf7 \strokec7 "add"\cf4 \strokec4 , \cf7 \strokec7 "addict"\cf4 \strokec4 , \cf7 \strokec7 "address"\cf4 \strokec4 , \cf7 \strokec7 "adjust"\cf4 \strokec4 , \cf7 \strokec7 "admit"\cf4 \strokec4 , \cf7 \strokec7 "adult"\cf4 \strokec4 ,\ + \cf7 \strokec7 "advance"\cf4 \strokec4 , \cf7 \strokec7 "advice"\cf4 \strokec4 , \cf7 \strokec7 "aerobic"\cf4 \strokec4 , \cf7 \strokec7 "affair"\cf4 \strokec4 , \cf7 \strokec7 "afford"\cf4 \strokec4 , \cf7 \strokec7 "afraid"\cf4 \strokec4 , \cf7 \strokec7 "again"\cf4 \strokec4 , \cf7 \strokec7 "age"\cf4 \strokec4 , \cf7 \strokec7 "agent"\cf4 \strokec4 , \cf7 \strokec7 "agree"\cf4 \strokec4 ,\ + \cf7 \strokec7 "ahead"\cf4 \strokec4 , \cf7 \strokec7 "aim"\cf4 \strokec4 , \cf7 \strokec7 "air"\cf4 \strokec4 , \cf7 \strokec7 "airport"\cf4 \strokec4 , \cf7 \strokec7 "aisle"\cf4 \strokec4 , \cf7 \strokec7 "alarm"\cf4 \strokec4 , \cf7 \strokec7 "album"\cf4 \strokec4 , \cf7 \strokec7 "alcohol"\cf4 \strokec4 , \cf7 \strokec7 "alert"\cf4 \strokec4 , \cf7 \strokec7 "alien"\cf4 \strokec4 ,\ + \cf7 \strokec7 "all"\cf4 \strokec4 , \cf7 \strokec7 "alley"\cf4 \strokec4 , \cf7 \strokec7 "allow"\cf4 \strokec4 , \cf7 \strokec7 "almost"\cf4 \strokec4 , \cf7 \strokec7 "alone"\cf4 \strokec4 , \cf7 \strokec7 "alpha"\cf4 \strokec4 , \cf7 \strokec7 "already"\cf4 \strokec4 , \cf7 \strokec7 "also"\cf4 \strokec4 , \cf7 \strokec7 "alter"\cf4 \strokec4 , \cf7 \strokec7 "always"\cf4 \strokec4 ,\ + \cf7 \strokec7 "amateur"\cf4 \strokec4 , \cf7 \strokec7 "amazing"\cf4 \strokec4 , \cf7 \strokec7 "among"\cf4 \strokec4 , \cf7 \strokec7 "amount"\cf4 \strokec4 , \cf7 \strokec7 "amused"\cf4 \strokec4 , \cf7 \strokec7 "analyst"\cf4 \strokec4 , \cf7 \strokec7 "anchor"\cf4 \strokec4 , \cf7 \strokec7 "ancient"\cf4 \strokec4 , \cf7 \strokec7 "anger"\cf4 \strokec4 , \cf7 \strokec7 "angle"\cf4 \strokec4 ,\ + \cf7 \strokec7 "angry"\cf4 \strokec4 , \cf7 \strokec7 "animal"\cf4 \strokec4 , \cf7 \strokec7 "ankle"\cf4 \strokec4 , \cf7 \strokec7 "announce"\cf4 \strokec4 , \cf7 \strokec7 "annual"\cf4 \strokec4 , \cf7 \strokec7 "another"\cf4 \strokec4 , \cf7 \strokec7 "answer"\cf4 \strokec4 , \cf7 \strokec7 "antenna"\cf4 \strokec4 , \cf7 \strokec7 "antique"\cf4 \strokec4 , \cf7 \strokec7 "anxiety"\cf4 \strokec4 ,\ + \cf7 \strokec7 "any"\cf4 \strokec4 , \cf7 \strokec7 "apart"\cf4 \strokec4 , \cf7 \strokec7 "apology"\cf4 \strokec4 , \cf7 \strokec7 "appear"\cf4 \strokec4 , \cf7 \strokec7 "apple"\cf4 \strokec4 , \cf7 \strokec7 "approve"\cf4 \strokec4 , \cf7 \strokec7 "april"\cf4 \strokec4 , \cf7 \strokec7 "arch"\cf4 \strokec4 , \cf7 \strokec7 "arctic"\cf4 \strokec4 , \cf7 \strokec7 "area"\cf4 \strokec4 ,\ + \cf7 \strokec7 "arena"\cf4 \strokec4 , \cf7 \strokec7 "argue"\cf4 \strokec4 , \cf7 \strokec7 "arm"\cf4 \strokec4 , \cf7 \strokec7 "armed"\cf4 \strokec4 , \cf7 \strokec7 "armor"\cf4 \strokec4 , \cf7 \strokec7 "army"\cf4 \strokec4 , \cf7 \strokec7 "around"\cf4 \strokec4 , \cf7 \strokec7 "arrange"\cf4 \strokec4 , \cf7 \strokec7 "arrest"\cf4 \strokec4 , \cf7 \strokec7 "arrive"\cf4 \strokec4 ,\ + +\f5\i \cf8 \strokec8 // ... (truncated for brevity - full 2048 word list needed in production) +\f3\i0 \cf4 \strokec4 \ + \cf7 \strokec7 "attack"\cf4 \strokec4 , \cf7 \strokec7 "pizza"\cf4 \strokec4 , \cf7 \strokec7 "motion"\cf4 \strokec4 , \cf7 \strokec7 "avocado"\cf4 \strokec4 , \cf7 \strokec7 "network"\cf4 \strokec4 , \cf7 \strokec7 "gather"\cf4 \strokec4 , \cf7 \strokec7 "crop"\cf4 \strokec4 , \cf7 \strokec7 "fresh"\cf4 \strokec4 , \cf7 \strokec7 "patrol"\cf4 \strokec4 , \cf7 \strokec7 "unusual"\cf4 \strokec4 ,\ + +\f5\i \cf8 \strokec8 // Full wordlist should be loaded from bip39 library +\f3\i0 \cf4 \strokec4 \ +];\ +\ +\pard\pardeftab720\partightenfactor0 + +\f5\i \cf8 \strokec8 // Full wordlist from bip39 +\f3\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 FULL_WORDLIST = bip39.wordlists.english;\ +\ + +\f4\b \cf6 \strokec6 interface +\f3\b0 \cf4 \strokec4 \cf5 \strokec5 SeedQRResult\cf4 \strokec4 \{\ + mnemonic: \cf9 \strokec9 string\cf4 \strokec4 ;\ + wordCount: \cf9 \strokec9 12\cf4 \strokec4 | \cf9 \strokec9 24\cf4 \strokec4 ;\ + format: \cf7 \strokec7 "standard"\cf4 \strokec4 | \cf7 \strokec7 "compact"\cf4 \strokec4 ;\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f5\i \cf8 \strokec8 // --- Standard SeedQR: Parse numeric digit stream --- +\f3\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f4\b \cf6 \strokec6 function +\f3\b0 \cf4 \strokec4 parseStandardSeedQR(digitStream: \cf9 \strokec9 string\cf4 \strokec4 ): SeedQRResult \{\ + +\f4\b \cf6 \strokec6 if +\f3\b0 \cf4 \strokec4 (digitStream.length % \cf9 \strokec9 4\cf4 \strokec4 !== \cf9 \strokec9 0\cf4 \strokec4 ) \{\ + +\f4\b \cf6 \strokec6 throw +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 new +\f3\b0 \cf4 \strokec4 \cf5 \strokec5 Error\cf4 \strokec4 (\cf7 \strokec7 "Invalid digit stream length. Must be multiple of 4."\cf4 \strokec4 );\ + \}\ +\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 wordIndices: \cf9 \strokec9 number\cf4 \strokec4 [] = [];\ + \ + +\f5\i \cf8 \strokec8 // Split into 4-digit indices +\f3\i0 \cf4 \strokec4 \ + +\f4\b \cf6 \strokec6 for +\f3\b0 \cf4 \strokec4 ( +\f4\b \cf6 \strokec6 let +\f3\b0 \cf4 \strokec4 i = \cf9 \strokec9 0\cf4 \strokec4 ; i < digitStream.length; i += \cf9 \strokec9 4\cf4 \strokec4 ) \{\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 indexStr = digitStream.slice(i, i + \cf9 \strokec9 4\cf4 \strokec4 );\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 index = parseInt(indexStr, \cf9 \strokec9 10\cf4 \strokec4 );\ + +\f4\b \cf6 \strokec6 if +\f3\b0 \cf4 \strokec4 (isNaN(index) || index >= \cf9 \strokec9 2048\cf4 \strokec4 ) \{\ + +\f4\b \cf6 \strokec6 throw +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 new +\f3\b0 \cf4 \strokec4 \cf5 \strokec5 Error\cf4 \strokec4 (\cf7 \strokec7 `Invalid word index: \cf4 \strokec4 $\{indexStr\}\cf7 \strokec7 `\cf4 \strokec4 );\ + \}\ + wordIndices.push(index);\ + \}\ +\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 wordCount = wordIndices.length;\ + +\f4\b \cf6 \strokec6 if +\f3\b0 \cf4 \strokec4 (wordCount !== \cf9 \strokec9 12\cf4 \strokec4 && wordCount !== \cf9 \strokec9 24\cf4 \strokec4 ) \{\ + +\f4\b \cf6 \strokec6 throw +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 new +\f3\b0 \cf4 \strokec4 \cf5 \strokec5 Error\cf4 \strokec4 (\cf7 \strokec7 `Invalid word count: \cf4 \strokec4 $\{wordCount\}\cf7 \strokec7 . Must be 12 or 24.`\cf4 \strokec4 );\ + \}\ +\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 mnemonicWords = wordIndices.map(index => FULL_WORDLIST[index]);\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 mnemonic = mnemonicWords.join(\cf7 \strokec7 " "\cf4 \strokec4 );\ +\ + +\f4\b \cf6 \strokec6 return +\f3\b0 \cf4 \strokec4 \{\ + mnemonic,\ + wordCount,\ + format: \cf7 \strokec7 "standard"\cf4 \strokec4 \ + \};\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f5\i \cf8 \strokec8 // --- Compact SeedQR: Parse binary entropy (128 bits for 12-word, 256 bits for 24-word) --- +\f3\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f4\b \cf6 \strokec6 function +\f3\b0 \cf4 \strokec4 parseCompactSeedQR(hexEntropy: \cf9 \strokec9 string\cf4 \strokec4 ): SeedQRResult \{\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 entropy = Buffer.from(hexEntropy, \cf7 \strokec7 'hex'\cf4 \strokec4 );\ + \ + +\f4\b \cf6 \strokec6 if +\f3\b0 \cf4 \strokec4 (entropy.length !== \cf9 \strokec9 16\cf4 \strokec4 && entropy.length !== \cf9 \strokec9 32\cf4 \strokec4 ) \{\ + +\f4\b \cf6 \strokec6 throw +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 new +\f3\b0 \cf4 \strokec4 \cf5 \strokec5 Error\cf4 \strokec4 (\cf7 \strokec7 `Invalid entropy length: \cf4 \strokec4 $\{entropy.length\}\cf7 \strokec7 . Must be 16 (12-word) or 32 (24-word) bytes.`\cf4 \strokec4 );\ + \}\ +\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 wordCount = entropy.length === \cf9 \strokec9 16\cf4 \strokec4 ? \cf9 \strokec9 12\cf4 \strokec4 : \cf9 \strokec9 24\cf4 \strokec4 ;\ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 mnemonic = bip39.entropyToMnemonic(entropy);\ +\ + +\f4\b \cf6 \strokec6 return +\f3\b0 \cf4 \strokec4 \{\ + mnemonic,\ + wordCount,\ + format: \cf7 \strokec7 "compact"\cf4 \strokec4 \ + \};\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f5\i \cf8 \strokec8 // --- Test Vector 1 --- +\f3\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f4\b \cf6 \strokec6 async +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 function +\f3\b0 \cf4 \strokec4 runSeedSignerTests() \{\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 " +\f6 \uc0\u55358 \u56785 \u8205 \u55357 \u56620 +\f3 Testing SeedSigner SeedQR Format\\n"\cf4 \strokec4 );\ +\ + +\f5\i \cf8 \strokec8 // Test Vector 1: 24-word seed +\f3\i0 \cf4 \strokec4 \ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 testVector1 = \{\ + mnemonic: \cf7 \strokec7 "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire"\cf4 \strokec4 ,\ + standardDigitStream: \cf7 \strokec7 "011513251154012711900771041507421289190620080870026613431420201617920614089619290300152408010643"\cf4 \strokec4 ,\ + compactHexEntropy: \cf7 \strokec7 "0e54b64107f94cc0ccfae6a13dcbec3662154fec67e0e00999c07892597d190a"\cf4 \strokec4 \ + \};\ +\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 " +\f6 \uc0\u55357 \u56522 +\f3 Test Vector 1 (24-word)"\cf4 \strokec4 );\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 "Expected:"\cf4 \strokec4 , testVector1.mnemonic);\ +\ + +\f4\b \cf6 \strokec6 try +\f3\b0 \cf4 \strokec4 \{\ + +\f5\i \cf8 \strokec8 // Test Standard SeedQR +\f3\i0 \cf4 \strokec4 \ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 standardResult = parseStandardSeedQR(testVector1.standardDigitStream);\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 "\\n +\f6 \uc0\u9989 +\f3 Standard SeedQR PASSED"\cf4 \strokec4 );\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 ` Words: \cf4 \strokec4 $\{standardResult.wordCount\}\cf7 \strokec7 , Mnemonic: \cf4 \strokec4 $\{standardResult.mnemonic.slice(\cf9 \strokec9 0\cf4 \strokec4 , \cf9 \strokec9 50\cf4 \strokec4 )\}\cf7 \strokec7 ...`\cf4 \strokec4 );\ +\ + +\f5\i \cf8 \strokec8 // Test Compact SeedQR +\f3\i0 \cf4 \strokec4 \ + +\f4\b \cf6 \strokec6 const +\f3\b0 \cf4 \strokec4 compactResult = parseCompactSeedQR(testVector1.compactHexEntropy);\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 "\\n +\f6 \uc0\u9989 +\f3 Compact SeedQR PASSED"\cf4 \strokec4 );\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 ` Words: \cf4 \strokec4 $\{compactResult.wordCount\}\cf7 \strokec7 , Mnemonic: \cf4 \strokec4 $\{compactResult.mnemonic.slice(\cf9 \strokec9 0\cf4 \strokec4 , \cf9 \strokec9 50\cf4 \strokec4 )\}\cf7 \strokec7 ...`\cf4 \strokec4 );\ +\ + +\f4\b \cf6 \strokec6 if +\f3\b0 \cf4 \strokec4 (standardResult.mnemonic === testVector1.mnemonic && compactResult.mnemonic === testVector1.mnemonic) \{\ + \cf9 \strokec9 console\cf4 \strokec4 .log(\cf7 \strokec7 "\\n +\f6 \uc0\u55356 \u57225 +\f3 ALL TESTS PASSED!"\cf4 \strokec4 );\ + \}\ +\ + \} +\f4\b \cf6 \strokec6 catch +\f3\b0 \cf4 \strokec4 (error) \{\ + \cf9 \strokec9 console\cf4 \strokec4 .error(\cf7 \strokec7 " +\f6 \uc0\u10060 +\f3 Test failed:"\cf4 \strokec4 , error);\ + \}\ +\}\ +\ +\pard\pardeftab720\partightenfactor0 + +\f5\i \cf8 \strokec8 // Run tests +\f3\i0 \cf4 \strokec4 \ +runSeedSignerTests();\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Run Command\ +\pard\pardeftab720\qc\partightenfactor0 + +\f2\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f3\fs26 \cf2 \strokec2 bash\ +\pard\pardeftab720\partightenfactor0 +\cf4 \strokec4 bun run seedsigner-test.ts\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Expected Output\ +\pard\pardeftab720\qc\partightenfactor0 + +\f2\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f3\fs26 \cf2 \strokec2 text\ +\pard\pardeftab720\partightenfactor0 + +\f6 \cf4 \strokec4 \uc0\u55358 \u56785 \u8205 \u55357 \u56620 +\f3 Testing SeedSigner SeedQR Format\ +\ + +\f6 \uc0\u55357 \u56522 +\f3 Test Vector 1 (24-word)\ +Expected: attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire\ +\ + +\f6 \uc0\u9989 +\f3 Standard SeedQR PASSED\ + Words: 24, Mnemonic: attack pizza motion avocado network gather crop f...\ +\ + +\f6 \uc0\u9989 +\f3 Compact SeedQR PASSED\ + Words: 24, Mnemonic: attack pizza motion avocado network gather crop f...\ +\ + +\f6 \uc0\u55356 \u57225 +\f3 ALL TESTS PASSED!\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Key Differences from Krux KEF\ + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrt\brdrnil \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1160\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth3425\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth3052\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\qc\partightenfactor0 + +\fs24 \cf2 \strokec10 Feature\cf0 \cell +\pard\intbl\itap1\pardeftab720\qc\partightenfactor0 +\cf2 SeedSigner SeedQR\cf0 \cell +\pard\intbl\itap1\pardeftab720\qc\partightenfactor0 +\cf2 Krux KEF\cf0 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1160\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth3425\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth3052\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Purpose +\f0\b0 \cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Encode BIP39 mnemonic indices\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Encrypt arbitrary data\cf0 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1160\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth3425\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth3052\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1\b \cf2 Encryption +\f0\b0 \cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1\b \cf2 None +\f0\b0 (plain text)\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 AES-GCM + PBKDF2\cf0 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1160\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth3425\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth3052\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1\b \cf2 Formats +\f0\b0 \cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Standard (digits), Compact (binary)\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Versioned envelopes\cf0 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1160\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth3425\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth3052\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1\b \cf2 Input +\f0\b0 \cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 QR contains raw indices/entropy\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Base43-encoded encrypted data\cf0 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrt\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1160\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth3425\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth3052\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadt20 \clpadl20 \clpadb20 \clpadr20 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1\b \cf2 Output +\f0\b0 \cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 BIP39 mnemonic\cf0 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 Raw decrypted bytes\cf0 \cell \lastrow\row +\pard\pardeftab720\qc\partightenfactor0 + +\f2\fs22 \cf3 \strokec3 \ +\ +\pard\pardeftab720\sa298\partightenfactor0 + +\f1\b\fs36 \cf2 \strokec2 Usage in Your App\ +\pard\pardeftab720\qc\partightenfactor0 + +\f2\b0\fs22 \cf3 \strokec3 \ +\pard\pardeftab720\partightenfactor0 + +\f3\fs26 \cf2 \strokec2 typescript\ +\pard\pardeftab720\partightenfactor0 + +\f5\i \cf8 \strokec8 // Detect format automatically +\f3\i0 \cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 + +\f4\b \cf6 \strokec6 export +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 async +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 function +\f3\b0 \cf4 \strokec4 parseSeedQR(qrData: \cf9 \strokec9 string\cf4 \strokec4 ): \cf9 \strokec9 Promise\cf4 \strokec4 <\cf9 \strokec9 string\cf4 \strokec4 > \{\ + +\f5\i \cf8 \strokec8 // Check if it's numeric digits (Standard SeedQR) +\f3\i0 \cf4 \strokec4 \ + +\f4\b \cf6 \strokec6 if +\f3\b0 \cf4 \strokec4 (\cf11 \strokec11 /^\\d+$/\cf4 \strokec4 .test(qrData)) \{\ + +\f4\b \cf6 \strokec6 return +\f3\b0 \cf4 \strokec4 parseStandardSeedQR(qrData).mnemonic;\ + \}\ + \ + +\f5\i \cf8 \strokec8 // Check if it's hex (Compact SeedQR) +\f3\i0 \cf4 \strokec4 \ + +\f4\b \cf6 \strokec6 if +\f3\b0 \cf4 \strokec4 (\cf11 \strokec11 /^[0-9a-fA-F]+$/\cf4 \strokec4 .test(qrData)) \{\ + +\f4\b \cf6 \strokec6 return +\f3\b0 \cf4 \strokec4 parseCompactSeedQR(qrData).mnemonic;\ + \}\ + \ + +\f4\b \cf6 \strokec6 throw +\f3\b0 \cf4 \strokec4 +\f4\b \cf6 \strokec6 new +\f3\b0 \cf4 \strokec4 \cf5 \strokec5 Error\cf4 \strokec4 (\cf7 \strokec7 "Unsupported SeedQR format"\cf4 \strokec4 );\ +\}\ +\pard\pardeftab720\sa240\partightenfactor0 + +\f0\fs24 \cf2 \strokec2 The +\f3\fs26 bip39 +\f0\fs24 library handles the full English wordlist and checksum validation automatically!\ +} \ No newline at end of file diff --git a/REFERENCE/encryption_ui.py b/REFERENCE/encryption_ui.py new file mode 100644 index 0000000..e0bd4c1 --- /dev/null +++ b/REFERENCE/encryption_ui.py @@ -0,0 +1,715 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# 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. + +import time +from embit import bip39 +from binascii import hexlify +from ..display import DEFAULT_PADDING, FONT_HEIGHT, BOTTOM_PROMPT_LINE +from ..krux_settings import t, Settings +from ..encryption import QR_CODE_ITER_MULTIPLE +from krux import kef +from ..themes import theme +from . import ( + Page, + Menu, + MENU_CONTINUE, + ESC_KEY, + LETTERS, + UPPERCASE_LETTERS, + NUM_SPECIAL_1, + NUM_SPECIAL_2, + DIGITS, +) + +# Override constants for KEF envelope operations +OVERRIDE_ITERATIONS = 1 +OVERRIDE_VERSION = 2 +OVERRIDE_MODE = 3 +OVERRIDE_LABEL = 4 + +ENCRYPTION_KEY_MAX_LEN = 200 + + +def decrypt_kef(ctx, data): + """finds kef-envelope and returns data fully decrypted, else ValueError""" + from binascii import unhexlify + from krux.baseconv import base_decode, hint_encodings + + # nothing to decrypt or declined raises ValueError here, + # so callers can `except ValueError: pass`, then treat original data. + # If user decides to decrypt and fails with wrong key, then + # `KeyError("Failed to decrypt")` raised by `KEFEnvelope.unseal_ui()` + # will bubble up to caller. + err = "Not decrypted" # intentionally vague + + # if data is str, assume encoded, look for kef envelope + kef_envelope = None + if isinstance(data, str): + encodings = hint_encodings(data) + for encoding in encodings: + as_bytes = None + if encoding in ("hex", "HEX"): + try: + as_bytes = unhexlify(data) + except: + continue + elif encoding == 32: + try: + as_bytes = base_decode(data, 32) + except: + continue + elif encoding == 64: + try: + as_bytes = base_decode(data, 64) + except: + continue + elif encoding == 43: + try: + as_bytes = base_decode(data, 43) + except: + continue + + if as_bytes: + kef_envelope = KEFEnvelope(ctx) + if kef_envelope.parse(as_bytes): + break + kef_envelope = None + del as_bytes + + # kef_envelope may already be parsed, else do so or fail early + if kef_envelope is None: + if not isinstance(data, bytes): + raise ValueError(err) + + kef_envelope = KEFEnvelope(ctx) + if not kef_envelope.parse(data): + raise ValueError(err) + + # unpack as many kef_envelopes as there may be + while True: + data = kef_envelope.unseal_ui() + if data is None: + # fail if not unsealed + raise ValueError(err) + # we may have unsealed another envelope + kef_envelope = KEFEnvelope(ctx) + if not kef_envelope.parse(data): + return data + raise ValueError(err) + + +def prompt_for_text_update( + ctx, + dflt_value, + dflt_prompt=None, + dflt_affirm=True, + prompt_highlight_prefix="", + title=None, + keypads=None, + esc_prompt=False, +): + """Clears screen, prompts question, allows for keypad input""" + if dflt_value: + if dflt_prompt: + dflt_prompt += " " + dflt_value + else: + dflt_prompt = t("Use current value?") + " " + dflt_value + ctx.display.clear() + if dflt_value and dflt_prompt: + ctx.display.draw_centered_text( + dflt_prompt, highlight_prefix=prompt_highlight_prefix + ) + dflt_answer = Page(ctx).prompt("", BOTTOM_PROMPT_LINE) + if dflt_affirm == dflt_answer: + return dflt_value + if not isinstance(keypads, list) or keypads is None: + keypads = [LETTERS, UPPERCASE_LETTERS, NUM_SPECIAL_1, NUM_SPECIAL_2] + value = Page(ctx).capture_from_keypad( + title, keypads, starting_buffer=dflt_value, esc_prompt=esc_prompt + ) + if isinstance(value, str): + return value + return dflt_value + + +class KEFEnvelope(Page): + """UI to handle KEF-Encryption-Format Envelopes""" + + def __init__(self, ctx): + super().__init__(ctx, None) + self.ctx = ctx + self.__key = None + self.__iv = None + self.label = None + self.iterations = Settings().encryption.pbkdf2_iterations + max_delta = self.iterations // 10 + self.iterations += int(time.ticks_ms()) % max_delta + self.mode_name = Settings().encryption.version + self.mode = kef.MODE_NUMBERS[self.mode_name] + self.iv_len = kef.MODE_IVS.get(self.mode, 0) + self.version = None + self.version_name = None + self.ciphertext = None + + def parse(self, kef_envelope): + """parses envelope, from kef.wrap()""" + if self.ciphertext is not None: + raise ValueError("KEF Envelope already parsed") + try: + self.label, self.version, self.iterations, self.ciphertext = kef.unwrap( + kef_envelope + ) + except: + return False + self.version_name = kef.VERSIONS[self.version]["name"] + self.mode = kef.VERSIONS[self.version]["mode"] + self.mode_name = [k for k, v in kef.MODE_NUMBERS.items() if v == self.mode][0] + return True + + def input_key_ui(self, creating=True): + """calls ui to gather master key""" + ui = EncryptionKey(self.ctx) + self.__key = ui.encryption_key(creating) + return bool(self.__key) + + def input_mode_ui(self): + """implements ui to allow user to select KEF mode-of-operation""" + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Use default Mode?") + " " + self.mode_name, highlight_prefix="?" + ) + if self.prompt("", BOTTOM_PROMPT_LINE): + return True + menu_items = [(k, v) for k, v in kef.MODE_NUMBERS.items() if v is not None] + idx, _ = Menu( + self.ctx, [(x[0], lambda: None) for x in menu_items], back_label=None + ).run_loop() + self.mode_name, self.mode = menu_items[idx] + self.iv_len = kef.MODE_IVS.get(self.mode, 0) + return True + + def input_version_ui(self): + """implements ui to allow user to select KEF version""" + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Use default Mode?") + " " + self.mode_name, highlight_prefix="?" + ) + if self.prompt("", BOTTOM_PROMPT_LINE): + return True + menu_items = [ + (v["name"], k) + for k, v in sorted(kef.VERSIONS.items()) + if isinstance(v, dict) and v["mode"] is not None + ] + idx, _ = Menu( + self.ctx, [(x[0], lambda: None) for x in menu_items], back_label=None + ).run_loop() + self.version = [v for i, (_, v) in enumerate(menu_items) if i == idx][0] + self.version_name = kef.VERSIONS[self.version]["name"] + self.mode = kef.VERSIONS[self.version]["mode"] + self.mode_name = [k for k, v in kef.MODE_NUMBERS.items() if v == self.mode][0] + self.iv_len = kef.MODE_IVS.get(self.mode, 0) + return True + + def input_iterations_ui(self): + """implements ui to allow user to set key-stretch iterations""" + curr_value = str(self.iterations) + dflt_prompt = t("Use default PBKDF2 iter.?") + title = t("PBKDF2 iter.") + ": 10K - 510K" + keypads = [DIGITS] + iterations = prompt_for_text_update( + self.ctx, curr_value, dflt_prompt, True, "?", title, keypads + ) + if QR_CODE_ITER_MULTIPLE <= int(iterations) <= 550000: + self.iterations = int(iterations) + return True + return None + + def input_label_ui( + self, + dflt_label="", + dflt_prompt="", + dflt_affirm=True, + title=t("Visible Label"), + keypads=None, + ): + """implements ui to allow user to set a KEF label""" + if dflt_label and not dflt_prompt: + dflt_prompt = t("Update KEF ID?") + dflt_affirm = False + self.label = prompt_for_text_update( + self.ctx, dflt_label, dflt_prompt, dflt_affirm, "?", title, keypads + ) + return True + + def input_iv_ui(self): + """implements ui to allow user to gather entropy from camera for iv""" + if self.iv_len > 0: + error_txt = t("Failed gathering camera entropy") + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Additional entropy from camera required for %s") % self.mode_name + ) + if not self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE): + self.flash_error(error_txt) + self.__iv = None + return None + from .capture_entropy import CameraEntropy + + camera_entropy = CameraEntropy(self.ctx) + entropy = camera_entropy.capture(show_entropy_details=False) + if entropy is None: + self.flash_error(error_txt) + self.__iv = None + return None + self.__iv = entropy[: self.iv_len] + return True + self.__iv = None + return True + + def public_info_ui(self, kef_envelope=None, prompt_decrypt=False): + """implements ui to allow user to see public exterior of KEF envelope""" + if kef_envelope: + self.parse(kef_envelope) + elif not self.ciphertext: + raise ValueError("KEF Envelope not yet parsed") + try: + displayable_label = self.label.decode() + except: + displayable_label = "0x" + hexlify(self.label).decode() + + public_info = "\n".join( + [ + t("KEF Encrypted") + " (" + str(len(self.ciphertext)) + " B)", + self.fit_to_line(displayable_label, t("ID") + ": "), + t("Version") + ": " + self.version_name, + t("PBKDF2 iter.") + ": " + str(self.iterations), + ] + ) + self.ctx.display.clear() + if prompt_decrypt: + return self.prompt( + public_info + "\n\n" + t("Decrypt?"), self.ctx.display.height() // 2 + ) + self.ctx.display.draw_hcentered_text(public_info) + self.ctx.input.wait_for_button() + return True + + def seal_ui( + self, + plaintext, + overrides=None, + dflt_label_prompt="", + dflt_label_affirm=True, + ): + """implements ui to allow user to seal plaintext inside a KEF envelope""" + if not isinstance(overrides, list): + overrides = [] + if self.ciphertext: + raise ValueError("KEF Envelope already sealed") + if not (self.__key or self.input_key_ui()): + return None + if overrides: + if OVERRIDE_ITERATIONS in overrides and not self.input_iterations_ui(): + return None + if OVERRIDE_VERSION in overrides and not self.input_version_ui(): + return None + if OVERRIDE_MODE in overrides and not self.input_mode_ui(): + return None + if self.iv_len: + if not (self.__iv or self.input_iv_ui()): + return None + if OVERRIDE_LABEL in overrides or not self.label: + self.input_label_ui(self.label, dflt_label_prompt, dflt_label_affirm) + if self.version is None: + self.version = kef.suggest_versions(plaintext, self.mode_name)[0] + self.version_name = kef.VERSIONS[self.version]["name"] + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + cipher = kef.Cipher(self.__key, self.label, self.iterations) + self.ciphertext = cipher.encrypt(plaintext, self.version, self.__iv) + self.__key = None + self.__iv = None + return kef.wrap(self.label, self.version, self.iterations, self.ciphertext) + + def unseal_ui(self, kef_envelope=None, prompt_decrypt=True, display_plain=False): + """implements ui to allow user to unseal a plaintext from a sealed KEF envelope""" + if kef_envelope: + if not self.parse(kef_envelope): + return None + if not self.ciphertext: + raise ValueError("KEF Envelope not yet parsed") + if prompt_decrypt: + if not self.public_info_ui(prompt_decrypt=prompt_decrypt): + return None + if not (self.__key or self.input_key_ui(creating=False)): + return None + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + cipher = kef.Cipher(self.__key, self.label, self.iterations) + plaintext = cipher.decrypt(self.ciphertext, self.version) + self.__key = None + if plaintext is None: + raise KeyError("Failed to decrypt") + if display_plain: + self.ctx.display.clear() + try: + self.ctx.display.draw_centered_text(plaintext.decode()) + except: + self.ctx.display.draw_centered_text("0x" + hexlify(plaintext).decode()) + self.ctx.input.wait_for_button() + return plaintext + + +class EncryptionKey(Page): + """UI to capture an encryption key""" + + def __init__(self, ctx): + super().__init__(ctx, None) + self.ctx = ctx + + def key_strength(self, key_string): + """Check the strength of a key.""" + + if isinstance(key_string, bytes): + key_string = hexlify(key_string).decode() + + if len(key_string) < 8: + return t("Weak") + + has_upper = has_lower = has_digit = has_special = False + + for c in key_string: + if "a" <= c <= "z": + has_lower = True + elif "A" <= c <= "Z": + has_upper = True + elif "0" <= c <= "9": + has_digit = True + else: + has_special = True + + # small optimization: stop if all found + if has_upper and has_lower and has_digit and has_special: + break + + # Count how many character types are present + score = sum([has_upper, has_lower, has_digit, has_special]) + + # Add length score to score + key_len = len(key_string) + if key_len >= 12: + score += 1 + if key_len >= 16: + score += 1 + if key_len >= 20: + score += 1 + if key_len >= 40: + score += 1 + + set_len = len(set(key_string)) + if set_len < 6: + score -= 1 + if set_len < 3: + score -= 1 + + # Determine key strength + if score >= 4: + return t("Strong") + if score >= 3: + return t("Medium") + return t("Weak") + + def encryption_key(self, creating=False): + """Loads and returns an encryption key from keypad or QR code""" + submenu = Menu( + self.ctx, + [ + (t("Type Key"), self.load_key), + (t("Scan Key QR Code"), self.load_qr_encryption_key), + ], + back_label=None, + ) + _, key = submenu.run_loop() + + try: + # encryption key may have been encrypted + decrypted = decrypt_kef(self.ctx, key) + try: + # no assumed decodings except for utf8 + decrypted = decrypted.decode() + except: + pass + + key = decrypted if decrypted else key + except KeyError: + self.flash_error(t("Failed to decrypt")) + return None + except ValueError: + # ValueError=not KEF or declined to decrypt + pass + + while True: + if key in (None, "", b"", ESC_KEY, MENU_CONTINUE): + self.flash_error(t("Failed to load")) + return None + + self.ctx.display.clear() + offset_y = DEFAULT_PADDING + displayable = key if isinstance(key, str) else "0x" + hexlify(key).decode() + key_lines = self.ctx.display.draw_hcentered_text( + "{} ({}): {}".format(t("Key"), len(key), displayable), + offset_y, + highlight_prefix=":", + ) + + if creating: + strength = self.key_strength(key) + offset_y += (key_lines + 1) * FONT_HEIGHT + color = theme.error_color if strength == t("Weak") else theme.fg_color + self.ctx.display.draw_hcentered_text( + "{}: {}".format(t("Strength"), strength), + offset_y, + color, + highlight_prefix=":", + ) + + if self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE): + return key + + # user did not confirm to proceed + if not isinstance(key, str): + return None + key = self.load_key(key) + + def load_key(self, data=""): + """Loads and returns a key from keypad""" + if not isinstance(data, str): + raise TypeError("load_key() expected str") + data = self.capture_from_keypad( + t("Key"), + [LETTERS, UPPERCASE_LETTERS, NUM_SPECIAL_1, NUM_SPECIAL_2], + starting_buffer=data, + ) + if len(str(data)) > ENCRYPTION_KEY_MAX_LEN: + raise ValueError("Maximum length exceeded (%s)" % ENCRYPTION_KEY_MAX_LEN) + return data + + def load_qr_encryption_key(self): + """Loads and returns a key from a QR code""" + + from .qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + data, _ = qr_capture.qr_capture_loop() + if data is None: + return None + if len(data) > ENCRYPTION_KEY_MAX_LEN: + raise ValueError("Maximum length exceeded (%s)" % ENCRYPTION_KEY_MAX_LEN) + return data + + +class EncryptMnemonic(Page): + """UI with mnemonic encryption output options""" + + def __init__(self, ctx): + super().__init__(ctx, None) + self.ctx = ctx + self.mode_name = Settings().encryption.version + + def _encrypt_mnemonic_with_label(self): + """Helper method to encrypt mnemonic with label selection.""" + + kef_envelope = KEFEnvelope(self.ctx) + default_label = self.ctx.wallet.key.fingerprint_hex_str() + kef_envelope.label = default_label + mnemonic_bytes = bip39.mnemonic_to_bytes(self.ctx.wallet.key.mnemonic) + encrypted_data = kef_envelope.seal_ui( + mnemonic_bytes, + overrides=[OVERRIDE_LABEL], + dflt_label_prompt=t("Use fingerprint as ID?"), + dflt_label_affirm=True, + ) + if encrypted_data is None: + return None, None + + mnemonic_id = kef_envelope.label + return encrypted_data, mnemonic_id + + def encrypt_menu(self): + """Menu with mnemonic encryption output options""" + + encrypt_outputs_menu = [ + (t("Store on Flash"), self.store_mnemonic_on_memory), + ( + t("Store on SD Card"), + ( + None + if not self.has_sd_card() + else lambda: self.store_mnemonic_on_memory(True) + ), + ), + (t("Encrypted QR Code"), self.encrypted_qr_code), + ] + submenu = Menu(self.ctx, encrypt_outputs_menu) + _, _ = submenu.run_loop() + return MENU_CONTINUE + + def store_mnemonic_on_memory(self, sd_card=False): + """Save encrypted mnemonic on flash or sd_card""" + + from ..encryption import MnemonicStorage + + encrypted_data, mnemonic_id = self._encrypt_mnemonic_with_label() + if encrypted_data is None: + return + + mnemonic_storage = MnemonicStorage() + if mnemonic_id in mnemonic_storage.list_mnemonics(sd_card): + self.flash_error( + t("ID already exists") + "\n" + t("Encrypted mnemonic was not stored") + ) + del mnemonic_storage + return + + if mnemonic_storage.store_encrypted_kef(mnemonic_id, encrypted_data, sd_card): + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Encrypted mnemonic stored with ID:") + " " + mnemonic_id, + highlight_prefix=":", + ) + else: + self.ctx.display.clear() + self.ctx.display.draw_centered_text( + t("Failed to store mnemonic"), theme.error_color + ) + self.ctx.input.wait_for_button() + del mnemonic_storage + + def encrypted_qr_code(self): + """Exports an encryprted mnemonic QR code""" + + encrypted_data, mnemonic_id = self._encrypt_mnemonic_with_label() + if encrypted_data is None: + return + + from .qr_view import SeedQRView + from ..baseconv import base_encode + + # All currently offered versions should encode to base43 + qr_data = base_encode(encrypted_data, 43) + seed_qr_view = SeedQRView(self.ctx, data=qr_data, title=mnemonic_id) + seed_qr_view.display_qr(allow_export=True) + + +class LoadEncryptedMnemonic(Page): + """UI to load encrypted mnemonics stored on flash and Sd card""" + + def __init__(self, ctx): + super().__init__(ctx, None) + self.ctx = ctx + + def load_from_storage(self, remove_opt=False): + """Lists all encrypted mnemonics stored is flash and SD card""" + from ..encryption import MnemonicStorage + from ..settings import THIN_SPACE + + mnemonic_ids_menu = [] + mnemonic_storage = MnemonicStorage() + mnemonics = mnemonic_storage.list_mnemonics() + sd_mnemonics = mnemonic_storage.list_mnemonics(sd_card=True) + del mnemonic_storage + + for mnemonic_id in sorted(mnemonics): + mnemonic_ids_menu.append( + ( + mnemonic_id + " (flash)", + lambda m_id=mnemonic_id: ( + self._remove_encrypted_mnemonic(m_id) + if remove_opt + else self._load_encrypted_mnemonic(m_id) + ), + ) + ) + for mnemonic_id in sorted(sd_mnemonics): + mnemonic_ids_menu.append( + ( + mnemonic_id + " (SD" + THIN_SPACE + "card)", + lambda m_id=mnemonic_id: ( + self._remove_encrypted_mnemonic(m_id, sd_card=True) + if remove_opt + else self._load_encrypted_mnemonic(m_id, sd_card=True) + ), + ) + ) + submenu = Menu(self.ctx, mnemonic_ids_menu) + index, status = submenu.run_loop() + if index == submenu.back_index: + return MENU_CONTINUE + return status + + def _load_encrypted_mnemonic(self, mnemonic_id, sd_card=False): + """Uses encryption module to load and decrypt a mnemonic""" + from ..encryption import MnemonicStorage + + error_txt = t("Failed to decrypt") + + key_capture = EncryptionKey(self.ctx) + key = key_capture.encryption_key() + if key in (None, "", ESC_KEY): + self.flash_error(t("Key was not provided")) + return MENU_CONTINUE + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + mnemonic_storage = MnemonicStorage() + try: + words = mnemonic_storage.decrypt(key, mnemonic_id, sd_card).split() + except: + self.flash_error(error_txt) + return MENU_CONTINUE + + if len(words) not in (12, 24): + self.flash_error(error_txt) + return MENU_CONTINUE + del mnemonic_storage + return words + + def _remove_encrypted_mnemonic(self, mnemonic_id, sd_card=False): + """Deletes a mnemonic""" + from ..encryption import MnemonicStorage + + mnemonic_storage = MnemonicStorage() + self.ctx.display.clear() + if self.prompt(t("Remove %s?") % mnemonic_id, self.ctx.display.height() // 2): + mnemonic_storage.del_mnemonic(mnemonic_id, sd_card) + message = t("%s removed.") % mnemonic_id + message += "\n\n" + if sd_card: + message += t( + "Fully erase your SD card in another device to ensure data is unrecoverable" + ) + else: + message += t("To ensure data is unrecoverable use Wipe Device feature") + self.ctx.display.clear() + self.ctx.display.draw_centered_text(message) + self.ctx.input.wait_for_button() + del mnemonic_storage diff --git a/REFERENCE/kef.py b/REFERENCE/kef.py new file mode 100644 index 0000000..42aab1a --- /dev/null +++ b/REFERENCE/kef.py @@ -0,0 +1,564 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2025 Krux contributors + +# 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. + +import ucryptolib +import uhashlib_hw + + +# KEF: AES, MODEs VERSIONS, MODE_NUMBERS, and MODE_IVS are defined here +# to disable a MODE: set its value to None +# to disable a VERSION: set its value to None +AES = ucryptolib.aes +MODE_ECB = ucryptolib.MODE_ECB +MODE_CBC = ucryptolib.MODE_CBC +MODE_CTR = ucryptolib.MODE_CTR +MODE_GCM = ucryptolib.MODE_GCM +VERSIONS = { + # initial versions: released 2023.08 to encrypt bip39 entropy bytes + 0: { + "name": "AES-ECB v1", + "mode": MODE_ECB, + "auth": -16, + }, + 1: { + "name": "AES-CBC v1", + "mode": MODE_CBC, + "auth": -16, + }, + # AES in ECB mode + 5: { + # smallest ECB ciphertext, w/ unsafe padding: for high entropy mnemonics, passphrases, etc + "name": "AES-ECB", + "mode": MODE_ECB, + "auth": 3, + }, + 6: { + # safe padding: for mid-sized plaintext w/o duplicate blocks + "name": "AES-ECB +p", + "mode": MODE_ECB, + "pkcs_pad": True, + "auth": -4, + }, + 7: { + # compressed, w/ safe padding: for larger plaintext; may compact otherwise duplicate blocks + "name": "AES-ECB +c", + "mode": MODE_ECB, + "pkcs_pad": True, + "auth": -4, + "compress": True, + }, + # AES in CBC mode + 10: { + # smallest CBC cipherext, w/ unsafe padding: for mnemonics, passphrases, etc + "name": "AES-CBC", + "mode": MODE_CBC, + "auth": 4, + }, + 11: { + # safe padding: for mid-sized plaintext + "name": "AES-CBC +p", + "mode": MODE_CBC, + "pkcs_pad": True, + "auth": -4, + }, + 12: { + # compressed, w/ safe padding: for larger plaintext + "name": "AES-CBC +c", + "mode": MODE_CBC, + "pkcs_pad": True, + "auth": -4, + "compress": True, + }, + # AES in CTR stream mode + 15: { + # doesn't require padding: for small and mid-sized plaintext + "name": "AES-CTR", + "mode": MODE_CTR, + "pkcs_pad": None, + "auth": -4, + }, + 16: { + # compressed: for larger plaintext + "name": "AES-CTR +c", + "mode": MODE_CTR, + "pkcs_pad": None, + "auth": -4, + "compress": True, + }, + # AES in GCM stream mode + 20: { + # doesn't require padding: for small and mid-sized plaintext + "name": "AES-GCM", + "mode": MODE_GCM, + "pkcs_pad": None, + "auth": 4, + }, + 21: { + # compressed: for larger plaintext + "name": "AES-GCM +c", + "mode": MODE_GCM, + "pkcs_pad": None, + "auth": 4, + "compress": True, + }, +} +MODE_NUMBERS = { + "AES-ECB": MODE_ECB, + "AES-CBC": MODE_CBC, + "AES-CTR": MODE_CTR, + "AES-GCM": MODE_GCM, +} +MODE_IVS = { + MODE_CBC: 16, + MODE_CTR: 12, + MODE_GCM: 12, +} + +AES_BLOCK_SIZE = 16 + + +class Cipher: + """More than just a helper for AES encrypt/decrypt. Enforces KEF VERSIONS rules""" + + def __init__(self, key, salt, iterations): + key = key if isinstance(key, bytes) else key.encode() + salt = salt if isinstance(salt, bytes) else salt.encode() + self._key = uhashlib_hw.pbkdf2_hmac_sha256(key, salt, iterations) + + def encrypt(self, plain, version, iv=b"", fail_unsafe=True): + """AES encrypt according to KEF rules defined by version, returns payload bytes""" + mode = VERSIONS[version]["mode"] + v_iv = MODE_IVS.get(mode, 0) + v_pkcs_pad = VERSIONS[version].get("pkcs_pad", False) + v_auth = VERSIONS[version].get("auth", 0) + v_compress = VERSIONS[version].get("compress", False) + auth = b"" + if iv is None: + iv = b"" + + if not isinstance(plain, bytes): + raise TypeError("Plaintext is not bytes") + + # for versions that compress + if v_compress: + plain = _deflate(plain) + + # fail: post-encryption appended "auth" with unfaithful-padding breaks decryption + if fail_unsafe and v_pkcs_pad is False and v_auth > 0 and plain[-1] == 0x00: + raise ValueError("Cannot validate decryption for this plaintext") + + # for modes that don't have authentication, KEF uses 2 forms of sha256 + if v_auth != 0 and mode in (MODE_ECB, MODE_CBC, MODE_CTR): + if v_auth > 0: + # unencrypted (public) auth: hash the plaintext w/ self._key + auth = uhashlib_hw.sha256( + bytes([version]) + iv + plain + self._key + ).digest()[:v_auth] + elif v_auth < 0: + # encrypted auth: hash only the plaintext + auth = uhashlib_hw.sha256(plain).digest()[:-v_auth] + + # fail: same case as above if auth bytes have NUL suffix + if fail_unsafe and v_pkcs_pad is False and auth[-1] == 0x00: + raise ValueError("Cannot validate decryption for this plaintext") + plain += auth + auth = b"" + + # some modes need to pad to AES 16-byte blocks + if v_pkcs_pad is True or v_pkcs_pad is False: + plain = _pad(plain, pkcs_pad=v_pkcs_pad) + + # fail to encrypt in modes where it is known unsafe + if fail_unsafe and mode == MODE_ECB: + unique_blocks = len( + set((plain[x : x + 16] for x in range(0, len(plain), 16))) + ) + if unique_blocks != len(plain) // 16: + raise ValueError("Duplicate blocks in ECB mode") + + # setup the encryptor (checking for modes that need initialization-vector) + if v_iv > 0: + if not (isinstance(iv, bytes) and len(iv) == v_iv): + raise ValueError("Wrong IV length") + elif iv: + raise ValueError("IV is not required") + if iv: + if mode == MODE_CTR: + encryptor = AES(self._key, mode, nonce=iv) + elif mode == MODE_GCM: + encryptor = AES(self._key, mode, iv, mac_len=v_auth) + else: + encryptor = AES(self._key, mode, iv) + else: + encryptor = AES(self._key, mode) + + # encrypt the plaintext + encrypted = encryptor.encrypt(plain) + + # for modes that do have inherent authentication, use it + if mode == MODE_GCM: + auth = encryptor.digest()[:v_auth] + + return iv + encrypted + auth + + def decrypt(self, payload, version): + """AES Decrypt according to KEF rules defined by version, returns plaintext bytes""" + mode = VERSIONS[version]["mode"] + v_iv = MODE_IVS.get(mode, 0) + v_pkcs_pad = VERSIONS[version].get("pkcs_pad", False) + v_auth = VERSIONS[version].get("auth", 0) + v_compress = VERSIONS[version].get("compress", False) + + # validate payload size early + min_payload = 1 if mode in (MODE_CTR, MODE_GCM) else AES_BLOCK_SIZE + min_payload += min(0, v_auth) + v_iv + if len(payload) <= min_payload: + raise ValueError("Invalid Payload") + + # setup decryptor (pulling initialization-vector from payload if necessary) + if not v_iv: + iv = b"" + decryptor = AES(self._key, mode) + else: + iv = payload[:v_iv] + if mode == MODE_CTR: + decryptor = AES(self._key, mode, nonce=iv) + elif mode == MODE_GCM: + decryptor = AES(self._key, mode, iv, mac_len=v_auth) + else: + decryptor = AES(self._key, mode, iv) + payload = payload[v_iv:] + + # remove authentication from payload if suffixed to ciphertext + auth = None + if v_auth > 0: + auth = payload[-v_auth:] + payload = payload[:-v_auth] + + # decrypt the ciphertext + decrypted = decryptor.decrypt(payload) + + # if authentication added (inherent or added by KEF for ECB/CBC) + # then: unpad and validate via embeded authentication bytes + # else: let caller deal with unpad and auth + if v_auth != 0: + try: + decrypted = self._authenticate( + version, iv, decrypted, decryptor, auth, mode, v_auth, v_pkcs_pad + ) + except: + decrypted = None + + # for versions that compress + if decrypted and v_compress: + decrypted = _reinflate(decrypted) + + return decrypted + + def _authenticate( + self, version, iv, decrypted, aes_object, auth, mode, v_auth, v_pkcs_pad + ): + if not ( + isinstance(version, int) + and 0 <= version <= 255 + and isinstance(iv, bytes) + and isinstance(decrypted, bytes) + and ( + mode != MODE_GCM + or (hasattr(aes_object, "verify") and callable(aes_object.verify)) + ) + and (isinstance(auth, bytes) or auth is None) + and mode in MODE_NUMBERS.values() + and (isinstance(v_auth, int) and -32 <= v_auth <= 32) + and (v_pkcs_pad is True or v_pkcs_pad is False or v_pkcs_pad is None) + ): + raise ValueError("Invalid call to ._authenticate()") + + # some modes need to unpad + len_pre_unpad = len(decrypted) + if v_pkcs_pad in (False, True): + decrypted = _unpad(decrypted, pkcs_pad=v_pkcs_pad) + + if v_auth < 0: + # auth was added to plaintext + auth = decrypted[v_auth:] + decrypted = decrypted[:v_auth] + + # versions that have built-in authentication use their own + if mode == MODE_GCM: + try: + aes_object.verify(auth) + return decrypted + except: + return None + + # versions that don't have built-in authentication use 2 forms of sha256 + max_attempts = 1 + if v_pkcs_pad is False: + # NUL padding is imperfect, still attempt to authenticate -- up to a limit... + # ... lesser of num bytes unpadded and auth size+1, + 1 + max_attempts = min(len_pre_unpad - len(decrypted), abs(v_auth) + 1) + 1 + + for _ in range(max_attempts): + if v_auth > 0: + # for unencrypted (public) auth > 0: hash the decrypted w/ self._key + cksum = uhashlib_hw.sha256( + bytes([version]) + iv + decrypted + self._key + ).digest()[:v_auth] + else: + # for encrypted auth < 0: hash only the decrypted + cksum = uhashlib_hw.sha256(decrypted).digest()[:-v_auth] + if cksum == auth: + return decrypted + + if v_auth < 0: + # for next attempt, assume auth had NUL stripped by unpad() + decrypted += auth[:1] + auth = auth[1:] + b"\x00" + elif v_auth > 0: + # for next attempt, assume plaintext had NUL stripped by unpad() + decrypted += b"\x00" + return None + + +def suggest_versions(plaintext, mode_name): + """Suggests a krux encryption version based on plaintext and preferred mode""" + + small_thresh = 32 # if len(plaintext) <= small_thresh: it is small + big_thresh = 120 # if len(plaintext) >= big_thresh: it is big + + # gather metrics on plaintext + if not isinstance(plaintext, (bytes, str)): + raise TypeError("Plaintext is not bytes or str") + p_length = len(plaintext) + unique_blocks = len(set((plaintext[x : x + 16] for x in range(0, p_length, 16)))) + p_duplicates = bool(unique_blocks < p_length / 16) + if isinstance(plaintext, bytes): + p_nul_suffix = bool(plaintext[-1] == 0x00) + else: + p_nul_suffix = bool(plaintext.encode()[-1] == 0x00) + + candidates = [] + for version, values in VERSIONS.items(): + # strategy: eliminate bad choices of versions + # TODO: explore a strategy that cuts to the best one right away + + if values is None or values["mode"] is None: + continue + + # never use a version that is not the correct mode + if values["mode"] != MODE_NUMBERS[mode_name]: + continue + v_compress = values.get("compress", False) + v_auth = values.get("auth", 0) + v_pkcs_pad = values.get("pkcs_pad", False) + + # never use non-compressed ECB when plaintext has duplicate blocks + if p_duplicates and mode_name == "AES-ECB" and not v_compress: + continue + + # never use v1 versions since v2 is smaller + if mode_name in ("AES-ECB", "AES-CBC") and v_auth == -16: + continue + + # based on plaintext size + if p_length <= small_thresh: + # except unsafe ECB text... + if mode_name == "AES-ECB" and p_duplicates: + pass + else: + # ...never use pkcs when it's small and can keep it small + if v_pkcs_pad is True and not p_nul_suffix: + continue + # ...and never compress + if v_compress: + continue + else: + # never use non-safe padding for not-small plaintext + if v_pkcs_pad is False: + continue + + # except unsafe ECB text... + if mode_name == "AES-ECB" and p_duplicates: + pass + elif p_length < big_thresh: + # ...never use compressed for not-big plaintext + if v_compress: + continue + else: + # never use non-compressed for big plaintext + if not v_compress: + continue + + # never use a version with unsafe padding if plaintext ends 0x00 + if p_nul_suffix and v_pkcs_pad is False: + continue + + candidates.append(version) + return candidates + + +def wrap(id_, version, iterations, payload): + """ + Wraps inputs into KEF Encryption Format envelope, returns bytes + """ + + try: + # when wrapping, be tolerant about id_ as bytes or str + id_ = id_ if isinstance(id_, bytes) else id_.encode() + if not 0 <= len(id_) <= 252: + raise ValueError + len_id = len(id_).to_bytes(1, "big") + except: + raise ValueError("Invalid ID") + + try: + if not ( + 0 <= version <= 255 + and VERSIONS[version] is not None + and VERSIONS[version]["mode"] is not None + ): + raise ValueError + except: + raise ValueError("Invalid version") + + try: + if not isinstance(iterations, int): + raise ValueError + if iterations % 10000 == 0: + iterations = iterations // 10000 + if not 1 <= iterations <= 10000: + raise ValueError + else: + if not 10000 < iterations < 2**24: + raise ValueError + iterations = iterations.to_bytes(3, "big") + except: + raise ValueError("Invalid iterations") + + extra = MODE_IVS.get(VERSIONS[version]["mode"], 0) + if VERSIONS[version].get("auth", 0) > 0: + extra += VERSIONS[version]["auth"] + if not isinstance(payload, bytes): + raise ValueError("Payload is not bytes") + if VERSIONS[version].get("pkcs_pad", False) in (True, False): + if (len(payload) - extra) % 16 != 0: + raise ValueError("Ciphertext is not aligned") + if (len(payload) - extra) // 16 < 1: + raise ValueError("Ciphertext is too short") + + version = version.to_bytes(1, "big") + return b"".join([len_id, id_, version, iterations, payload]) + + +def unwrap(kef_bytes): + """ + Unwraps KEF Encryption Format bytes, returns tuple of parsed values + """ + len_id = kef_bytes[0] + + try: + # out-of-order reading to validate version early + version = kef_bytes[1 + len_id] + if VERSIONS[version] is None or VERSIONS[version]["mode"] is None: + raise ValueError + except: + raise ValueError("Invalid format") + + # When unwrapping, be strict returning id_ as bytes + id_ = kef_bytes[1 : 1 + len_id] + + kef_iterations = int.from_bytes(kef_bytes[2 + len_id : 5 + len_id], "big") + if kef_iterations <= 10000: + iterations = kef_iterations * 10000 + else: + iterations = kef_iterations + + payload = kef_bytes[len_id + 5 :] + extra = MODE_IVS.get(VERSIONS[version]["mode"], 0) + if VERSIONS[version].get("auth", 0) > 0: + extra += VERSIONS[version]["auth"] + if VERSIONS[version].get("pkcs_pad", False) in (True, False): + if (len(payload) - extra) % 16 != 0: + raise ValueError("Ciphertext is not aligned") + if (len(payload) - extra) // 16 < 1: + raise ValueError("Ciphertext is too short") + + return (id_, version, iterations, payload) + + +def _pad(some_bytes, pkcs_pad): + """ + Pads some_bytes to AES block size of 16 bytes, returns bytes + pkcs_pad: False=NUL-pad, True=PKCS#7-pad, None=no-pad + """ + if pkcs_pad is None: + return some_bytes + len_padding = (AES_BLOCK_SIZE - len(some_bytes) % AES_BLOCK_SIZE) % AES_BLOCK_SIZE + if pkcs_pad is True: + if len_padding == 0: + len_padding = AES_BLOCK_SIZE + return some_bytes + (len_padding).to_bytes(1, "big") * len_padding + if pkcs_pad is False: + return some_bytes + b"\x00" * len_padding + raise TypeError("pkcs_pad is not (None, True, False)") + + +def _unpad(some_bytes, pkcs_pad): + """ + Strips padding from some_bytes, returns bytes + pkcs_pad: False=NUL-pad, True=PKCS#7-pad, None=no-pad + """ + if pkcs_pad is None: + return some_bytes + if pkcs_pad is True: + len_padding = some_bytes[-1] + return some_bytes[:-len_padding] + if pkcs_pad is False: + return some_bytes.rstrip(b"\x00") + raise TypeError("pkcs_pad is not in (None, True, False)") + + +def _deflate(data): + """Compresses the given data using deflate module""" + import io + import deflate + + try: + stream = io.BytesIO() + with deflate.DeflateIO(stream) as d: + d.write(data) + return stream.getvalue() + except: + raise ValueError("Error compressing") + + +def _reinflate(data): + """Decompresses the given data using deflate module""" + import io + import deflate + + try: + with deflate.DeflateIO(io.BytesIO(data)) as d: + return d.read() + except: + raise ValueError("Error decompressing") diff --git a/REFERENCE/krux b/REFERENCE/krux new file mode 160000 index 0000000..bff65f4 --- /dev/null +++ b/REFERENCE/krux @@ -0,0 +1 @@ +Subproject commit bff65f4fb8bf8a52551f28179a624be5e9765e49 diff --git a/REFERENCE/krux-test/.gitignore b/REFERENCE/krux-test/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/REFERENCE/krux-test/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/REFERENCE/krux-test/README.md b/REFERENCE/krux-test/README.md new file mode 100644 index 0000000..f848e66 --- /dev/null +++ b/REFERENCE/krux-test/README.md @@ -0,0 +1,15 @@ +# krux-test + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run +``` + +This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/REFERENCE/krux-test/bun.lock b/REFERENCE/krux-test/bun.lock new file mode 100644 index 0000000..e899d70 --- /dev/null +++ b/REFERENCE/krux-test/bun.lock @@ -0,0 +1,33 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "krux-test", + "dependencies": { + "bip39": "^3.1.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + + "@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], + + "bip39": ["bip39@3.1.0", "", { "dependencies": { "@noble/hashes": "^1.2.0" } }, "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A=="], + + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/REFERENCE/krux-test/krux-test.ts b/REFERENCE/krux-test/krux-test.ts new file mode 100644 index 0000000..e21a8a4 --- /dev/null +++ b/REFERENCE/krux-test/krux-test.ts @@ -0,0 +1,123 @@ +import * as bip39 from "bip39"; +// Bun implements the Web Crypto API globally as `crypto` + +const BASE43_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:"; + +// --- Helper: Base43 Decode --- +function base43Decode(str: string): Uint8Array { + let value = 0n; + const base = 43n; + + for (const char of str) { + const index = BASE43_ALPHABET.indexOf(char); + if (index === -1) throw new Error(`Invalid Base43 char: ${char}`); + value = value * base + BigInt(index); + } + + // Convert BigInt to Buffer/Uint8Array + let hex = value.toString(16); + if (hex.length % 2 !== 0) hex = '0' + hex; + return new Uint8Array(Buffer.from(hex, 'hex')); +} + +// --- Main Decryption Function --- +async function decryptKruxKEF(kefData: string, passphrase: string) { + // 1. Decode Base43 + const rawBytes = base43Decode(kefData); + + // 2. Parse Envelope + let offset = 0; + + // ID Length (1 byte) + const idLen = rawBytes[offset++]; + + // ID / Salt + const salt = rawBytes.slice(offset, offset + idLen); + offset += idLen; + + // Version (1 byte) + const version = rawBytes[offset++]; + + // Iterations (3 bytes, Big-Endian) + const iterBytes = rawBytes.slice(offset, offset + 3); + const iterations = (iterBytes[0] << 16) | (iterBytes[1] << 8) | iterBytes[2]; + offset += 3; + + // Payload: [IV (12 bytes)] [Ciphertext (N)] [Tag (4 bytes)] + const payload = rawBytes.slice(offset); + const iv = payload.slice(0, 12); + const tagLength = 4; + const ciphertextWithTag = payload.slice(12); + + console.log("--- Parsed KEF Data ---"); + console.log(`Version: ${version}`); + console.log(`Iterations: ${iterations}`); + console.log(`Salt (hex): ${Buffer.from(salt).toString('hex')}`); + + if (version !== 20) { + throw new Error("Only KEF Version 20 (AES-GCM) is supported."); + } + + // 3. Derive Key (PBKDF2-HMAC-SHA256) + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + { name: "PBKDF2" }, + false, + ["deriveKey"] + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: iterations, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"] + ); + + // 4. Decrypt (AES-GCM) + try { + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + tagLength: 32, // 4 bytes * 8 + }, + key, + ciphertextWithTag + ); + + return new Uint8Array(decryptedBuffer); + + } catch (error) { + throw new Error(`Decryption failed: ${error}`); + } +} + +// --- Run Test --- +const kefString = "1334+HGXM$F8PPOIRNHX0.R*:SBMHK$X88LX$*/Y417R/6S1ZQOB2LHM-L+4T1YQVU:B*CKGXONP7:Y/R-B*:$R8FK"; +const passphrase = "aaa"; +const expectedMnemonic = "differ release beauty fresh tortoise usage curtain spoil october town embrace ridge rough reject cabin snap glimpse enter book coach green lonely hundred mercy"; + +console.log(`\nDecrypting KEF String...`); +try { + const entropy = await decryptKruxKEF(kefString, passphrase); + const mnemonic = bip39.entropyToMnemonic(Buffer.from(entropy)); + + console.log("\n--- Result ---"); + console.log(`Mnemonic: ${mnemonic}`); + + if (mnemonic === expectedMnemonic) { + console.log("\n✅ SUCCESS: Mnemonic matches expected output."); + } else { + console.log("\n❌ FAIL: Mnemonic does not match."); + } +} catch (e) { + console.error(e); +} + diff --git a/REFERENCE/krux-test/package.json b/REFERENCE/krux-test/package.json new file mode 100644 index 0000000..1399ed2 --- /dev/null +++ b/REFERENCE/krux-test/package.json @@ -0,0 +1,13 @@ +{ + "name": "krux-test", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "bip39": "^3.1.0" + } +} diff --git a/REFERENCE/krux-test/tsconfig.json b/REFERENCE/krux-test/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/REFERENCE/krux-test/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/REFERENCE/qr.py b/REFERENCE/qr.py new file mode 100644 index 0000000..34845e6 --- /dev/null +++ b/REFERENCE/qr.py @@ -0,0 +1,403 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# 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. +# pylint: disable=E1101 +import io +import math +import qrcode + +FORMAT_NONE = 0 +FORMAT_PMOFN = 1 +FORMAT_UR = 2 +FORMAT_BBQR = 3 + +PMOFN_PREFIX_LENGTH_1D = 6 +PMOFN_PREFIX_LENGTH_2D = 8 +BBQR_PREFIX_LENGTH = 8 +UR_GENERIC_PREFIX_LENGTH = 22 + +# CBOR_PREFIX = 6 bytes for tags, 1 for index, 1 for max_index, 2 for message len, 4 for checksum +# Check UR's fountain_encoder.py file, on Part.cbor() function for more details +UR_CBOR_PREFIX_LEN = 14 +UR_BYTEWORDS_CRC_LEN = 4 # 32 bits CRC used on Bytewords encoding + +UR_MIN_FRAGMENT_LENGTH = 10 + +# https://www.qrcode.com/en/about/version.html +# List of capacities, based on versions +# Tables below are limited to version 20 and we use L (Low) ECC (Error Correction Code) Level + +# [0-9] = 10 chars +# Version 1(index 0)=21x21px = 41 chars, version 2=25x25px = 77 chars ... +QR_CAPACITY_NUMERIC = [ + 41, + 77, + 127, + 187, + 255, + 322, + 370, + 461, + 552, + 652, + 772, + 883, + 1022, + 1101, + 1250, + 1408, + 1548, + 1725, + 1903, + 2061, +] + + +# [A-Z0-9 $%*+\-./:] = 45 chars (no lowercase!) +# Version 1(index 0)=21x21px = 25 chars, version 2=25x25px = 47 chars ... +QR_CAPACITY_ALPHANUMERIC = [ + 25, + 47, + 77, + 114, + 154, + 195, + 224, + 279, + 335, + 395, + 468, + 535, + 619, + 667, + 758, + 854, + 938, + 1046, + 1153, + 1249, +] + + +# ASCII, UTF-8 (any 8-bit / 1 byte sequence) +# Requires more pixels to show information +# Version 1(index 0)=21x21px = 17 bytes, version 2=25x25px = 32 bytes ... +QR_CAPACITY_BYTE = [ + 17, + 32, + 53, + 78, + 106, + 134, + 154, + 192, + 230, + 271, + 321, + 367, + 425, + 458, + 520, + 586, + 644, + 718, + 792, + 858, +] + + +class QRPartParser: + """Responsible for parsing either a singular or animated series of QR codes + and returning the final decoded, combined data + """ + + def __init__(self): + self.parts = {} + self.total = -1 + self.format = None + self.decoder = None + self.bbqr = None + + def parsed_count(self): + """Returns the number of parsed parts so far""" + if self.format == FORMAT_UR: + # Single-part URs have no expected part indexes + if self.decoder.fountain_decoder.expected_part_indexes is None: + return 1 if self.decoder.result is not None else 0 + completion_pct = self.decoder.estimated_percent_complete() + return math.ceil(completion_pct * self.total_count() / 2) + len( + self.decoder.fountain_decoder.received_part_indexes + ) + return len(self.parts) + + def processed_parts_count(self): + """Returns quantity of processed QR code parts""" + if self.format == FORMAT_UR: + return self.decoder.fountain_decoder.processed_parts_count + return len(self.parts) + + def total_count(self): + """Returns the total number of parts there should be""" + if self.format == FORMAT_UR: + # Single-part URs have no expected part indexes + if self.decoder.fountain_decoder.expected_part_indexes is None: + return 1 + return self.decoder.expected_part_count() * 2 + return self.total + + def parse(self, data): + """Parses the QR data, extracting part information""" + if self.format is None: + self.format, self.bbqr = detect_format(data) + + if self.format == FORMAT_NONE: + self.parts[1] = data + self.total = 1 + elif self.format == FORMAT_PMOFN: + part, index, total = parse_pmofn_qr_part(data) + self.parts[index] = part + self.total = total + return index - 1 + elif self.format == FORMAT_UR: + if not self.decoder: + from ur.ur_decoder import URDecoder + + self.decoder = URDecoder() + self.decoder.receive_part(data) + elif self.format == FORMAT_BBQR: + from .bbqr import parse_bbqr + + part, index, total = parse_bbqr(data) + self.parts[index] = part + self.total = total + return index + return None + + def is_complete(self): + """Returns a boolean indicating whether or not enough parts have been parsed""" + if self.format == FORMAT_UR: + return self.decoder.is_complete() + keys_check = ( + sum(range(1, self.total + 1)) + if self.format in (FORMAT_PMOFN, FORMAT_NONE) + else sum(range(self.total)) + ) + return ( + self.total != -1 + and self.parsed_count() == self.total_count() + and sum(self.parts.keys()) == keys_check + ) + + def result(self): + """Returns the combined part data""" + if self.format == FORMAT_UR: + from ur.ur import UR + + return UR(self.decoder.result.type, bytearray(self.decoder.result.cbor)) + + if self.format == FORMAT_BBQR: + from .bbqr import decode_bbqr + + return decode_bbqr(self.parts, self.bbqr.encoding, self.bbqr.file_type) + + code_buffer = io.StringIO("") + for _, part in sorted(self.parts.items()): + if isinstance(part, bytes): + # Encoded data won't write on StringIO + return part + code_buffer.write(part) + code = code_buffer.getvalue() + code_buffer.close() + return code + + +def to_qr_codes(data, max_width, qr_format): + """Returns the list of QR codes necessary to represent the data in the qr format, given + the max_width constraint + """ + if qr_format == FORMAT_NONE: + code = qrcode.encode(data) + yield (code, 1) + else: + num_parts, part_size = find_min_num_parts(data, max_width, qr_format) + if qr_format == FORMAT_PMOFN: + part_index = 0 + while True: + part_number = "p%dof%d " % (part_index + 1, num_parts) + if isinstance(data, bytes): + part_number = part_number.encode() + part = None + if part_index == num_parts - 1: + part = part_number + data[part_index * part_size :] + part_index = 0 + else: + part = ( + part_number + + data[part_index * part_size : (part_index + 1) * part_size] + ) + part_index += 1 + code = qrcode.encode(part) + yield (code, num_parts) + elif qr_format == FORMAT_UR: + from ur.ur_encoder import UREncoder + + encoder = UREncoder(data, part_size, 0) + while True: + part = encoder.next_part() + code = qrcode.encode(part) + yield (code, encoder.fountain_encoder.seq_len()) + elif qr_format == FORMAT_BBQR: + from .bbqr import int2base36 + + part_index = 0 + while True: + header = "B$%s%s%s%s" % ( + data.encoding, + data.file_type, + int2base36(num_parts), + int2base36(part_index), + ) + part = None + if part_index == num_parts - 1: + part = header + data.payload[part_index * part_size :] + part_index = 0 + else: + part = ( + header + + data.payload[ + part_index * part_size : (part_index + 1) * part_size + ] + ) + part_index += 1 + code = qrcode.encode(part) + yield (code, num_parts) + + +def get_size(qr_code): + """Returns the size of the qr code as the number of chars until the first newline""" + size = math.sqrt(len(qr_code) * 8) + return int(size) + + +def max_qr_bytes(max_width, encoding="byte"): + """Calculates the maximum length, in bytes, a QR code of a given size can store""" + # Given qr_size = 17 + 4 * version + 2 * frame_size + max_width -= 2 # Subtract frame width + qr_version = (max_width - 17) // 4 + if encoding == "alphanumeric": + capacity_list = QR_CAPACITY_ALPHANUMERIC + else: + capacity_list = QR_CAPACITY_BYTE + + try: + return capacity_list[qr_version - 1] + except: + # Limited to version 20 + return capacity_list[-1] + + +def find_min_num_parts(data, max_width, qr_format): + """Finds the minimum number of QR parts necessary to encode the data in + the specified format within the max_width constraint + """ + encoding = "alphanumeric" if qr_format == FORMAT_BBQR else "byte" + qr_capacity = max_qr_bytes(max_width, encoding) + if qr_format == FORMAT_PMOFN: + data_length = len(data) + part_size = qr_capacity - PMOFN_PREFIX_LENGTH_1D + # where prefix = "pXofY " where Y < 9 + num_parts = (data_length + part_size - 1) // part_size + if num_parts > 9: # Prefix has 2 digits numbers, so re-calculate + part_size = qr_capacity - PMOFN_PREFIX_LENGTH_2D + # where prefix = "pXXofYY " where max YY = 99 + num_parts = (data_length + part_size - 1) // part_size + part_size = (data_length + num_parts - 1) // num_parts + elif qr_format == FORMAT_UR: + qr_capacity -= ( + # This is an approximation, UR index grows indefinitely + UR_GENERIC_PREFIX_LENGTH # index: ~ "ur:crypto-psbt/xxx-xx/" + ) + # UR will add a bunch of info (some duplicated) on the body of each QR + # Info's lenght is multiplied by 2 in Bytewords.encode step + qr_capacity -= (UR_CBOR_PREFIX_LEN + UR_BYTEWORDS_CRC_LEN) * 2 + qr_capacity = max(UR_MIN_FRAGMENT_LENGTH, qr_capacity) + data_length = len(data.cbor) + data_length *= 2 # UR will Bytewords.encode, which multiply bytes length by 2 + num_parts = (data_length + qr_capacity - 1) // qr_capacity + # For UR, part size will be the input for "max_fragment_len" + part_size = len(data.cbor) // num_parts + part_size = max(part_size, UR_MIN_FRAGMENT_LENGTH) + # UR won't use "num_parts", will use encoder.fountain_encoder.seq_len() instead + elif qr_format == FORMAT_BBQR: + data_length = len(data.payload) + max_part_size = qr_capacity - BBQR_PREFIX_LENGTH + if data_length < max_part_size: + return 1, data_length + # Round max_part_size to the nearest lower multiple of 8 + max_part_size = (max_part_size // 8) * 8 + # Calculate the number of parts required (rounded up) + num_parts = (data_length + max_part_size - 1) // max_part_size + # Calculate the optimal part size + part_size = data_length // num_parts + # Round to the nearest higher multiple of 8 + part_size = ((part_size + 7) // 8) * 8 + # Check if the part size is within the limits + if part_size > max_part_size: + num_parts += 1 + part_size = data_length // num_parts + # Round to the nearest higher multiple of 8 again + part_size = ((part_size + 7) // 8) * 8 + else: + raise ValueError("Invalid format type") + return num_parts, part_size + + +def parse_pmofn_qr_part(data): + """Parses the QR as a P M-of-N part, extracting the part's content, index, and total""" + of_index = data.index("of") + space_index = data.index(" ") + part_index = int(data[1:of_index]) + part_total = int(data[of_index + 2 : space_index]) + return data[space_index + 1 :], part_index, part_total + + +def detect_format(data): + """Detects the QR format of the given data""" + qr_format = FORMAT_NONE + try: + if data.startswith("p"): + header = data.split(" ")[0] + if "of" in header and header[1:].split("of")[0].isdigit(): + qr_format = FORMAT_PMOFN + elif data.lower().startswith("ur:"): + qr_format = FORMAT_UR + elif data.startswith("B$"): + from .bbqr import BBQrCode, KNOWN_ENCODINGS, KNOWN_FILETYPES + + if data[3] in KNOWN_FILETYPES: + bbqr_file_type = data[3] + if data[2] in KNOWN_ENCODINGS: + bbqr_encoding = data[2] + return FORMAT_BBQR, BBQrCode(None, bbqr_encoding, bbqr_file_type) + + except: + pass + return qr_format, None diff --git a/REFERENCE/seeds-blender b/REFERENCE/seeds-blender new file mode 160000 index 0000000..79ceb56 --- /dev/null +++ b/REFERENCE/seeds-blender @@ -0,0 +1 @@ +Subproject commit 79ceb564f5e39eff4f81c12bcf9b5d67c959658b diff --git a/bun.lock b/bun.lock index d2073e5..cd68c69 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@types/pako": "^2.0.4", "html5-qrcode": "^2.3.8", + "jsqr": "^1.4.0", "lucide-react": "^0.462.0", "openpgp": "^6.3.0", "pako": "^2.1.0", @@ -18,6 +19,7 @@ "@types/bun": "^1.3.6", "@types/node": "^22.10.2", "@types/qrcode": "^1.5.5", + "@types/qrcode-generator": "^1.0.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", @@ -210,6 +212,8 @@ "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/qrcode-generator": ["@types/qrcode-generator@1.0.6", "", { "dependencies": { "qrcode-generator": "*" } }, "sha512-XasuPjhHBC4hyOJ/pHaUNTj+tNxA1SyZpXaS/FOUxEVX03D1gFM8UmMKSIs+pPHLAmRZpU6j9KYxvo+lfsvhKw=="], + "@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], @@ -324,6 +328,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -394,6 +400,8 @@ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], diff --git a/debug_krux.py b/debug_krux.py new file mode 100644 index 0000000..b959bc9 --- /dev/null +++ b/debug_krux.py @@ -0,0 +1,71 @@ +# +# This is a debug script to trace the Krux decryption process. +# It has been modified to be self-contained and avoid MicroPython-specific libraries. +# +import sys + +# Add the source directory to the path to allow imports +sys.path.append('REFERENCE/krux/src') + +from krux.baseconv import pure_python_base_decode + +def unwrap_standalone(kef_bytes): + """A self-contained version of kef.unwrap for debugging.""" + try: + len_id = kef_bytes[0] + if not (0 <= len_id <= 252): + raise ValueError(f"Invalid label length: {len_id}") + if len(kef_bytes) < (1 + len_id + 4): + raise ValueError("KEF bytes too short for header") + + id_ = kef_bytes[1 : 1 + len_id] + version = kef_bytes[1 + len_id] + kef_iterations = int.from_bytes(kef_bytes[2 + len_id : 5 + len_id], "big") + + if kef_iterations <= 10000: + iterations = kef_iterations * 10000 + else: + iterations = kef_iterations + + payload = kef_bytes[len_id + 5 :] + return (id_, version, iterations, payload) + except Exception as e: + raise ValueError(f"Failed to unwrap KEF envelope: {e}") + + +def debug_krux_decryption(): + # Test case from the user + base43_string = "1334+HGXM$F8PPOIRNHX0.R*:SBMHK$X88LX$*/Y417R/6S1ZQOB2LHM-L+4T1YQVU:B*CKGXONP7:Y/R-B*:$R8FK" + + print("--- Krux Decryption Debug (Phase 1: Decoding & Unwrapping) ---") + print(f"Input Base43: {base43_string}\n") + + # Step 1: Base43 Decode + try: + kef_envelope_bytes = pure_python_base_decode(base43_string, 43) + print(f"[OK] Step 1: Base43 Decoded (KEF Envelope Hex):") + print(kef_envelope_bytes.hex()) + print("-" * 20) + except Exception as e: + print(f"[FAIL] Step 1: Base43 Decoding failed: {e}") + return + + # Step 2: Unwrap KEF Envelope + try: + label_bytes, version, iterations, payload = unwrap_standalone(kef_envelope_bytes) + label = label_bytes.decode('utf-8', errors='ignore') + print(f"[OK] Step 2: KEF Unwrapped") + print(f" - Label: '{label}'") + print(f" - Version: {version}") + print(f" - Iterations: {iterations}") + print(f" - Payload (Hex): {payload.hex()}") + print("-" * 20) + print("\n--- DEBUGGING COMPLETE ---") + print("Please paste this entire output for analysis.") + + except Exception as e: + print(f"[FAIL] Step 2: KEF Unwrapping failed: {e}") + return + +if __name__ == '__main__': + debug_krux_decryption() diff --git a/package.json b/package.json index cb4d60a..7417c42 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@types/pako": "^2.0.4", "html5-qrcode": "^2.3.8", + "jsqr": "^1.4.0", "lucide-react": "^0.462.0", "openpgp": "^6.3.0", "pako": "^2.1.0", @@ -23,6 +24,7 @@ "@types/bun": "^1.3.6", "@types/node": "^22.10.2", "@types/qrcode": "^1.5.5", + "@types/qrcode-generator": "^1.0.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/src/App.tsx b/src/App.tsx index 38d1204..83477d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { QrCode, RefreshCw, @@ -15,9 +15,11 @@ import { QrDisplay } from './components/QrDisplay'; import QRScanner from './components/QRScanner'; import { validateBip39Mnemonic } from './lib/bip39'; import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode } from './lib/seedpgp'; +import { encodeStandardSeedQR, encodeCompactSeedQREntropy } from './lib/seedqr'; import * as openpgp from 'openpgp'; import { SecurityWarnings } from './components/SecurityWarnings'; import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto'; +import { EncryptionMode, EncryptionResult } from './lib/types'; // Import EncryptionMode and EncryptionResult import Header from './components/Header'; import { StorageDetails } from './components/StorageDetails'; import { ClipboardDetails } from './components/ClipboardDetails'; @@ -34,9 +36,9 @@ interface StorageItem { } interface ClipboardEvent { - timestamp: Date; - field: string; - length: number; + timestamp: Date; + field: string; + length: number; } function App() { @@ -51,7 +53,7 @@ function App() { const [privateKeyInput, setPrivateKeyInput] = useState(''); const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState(''); const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false); - const [qrPayload, setQrPayload] = useState(''); + const [qrPayload, setQrPayload] = useState(''); const [recipientFpr, setRecipientFpr] = useState(''); const [restoreInput, setRestoreInput] = useState(''); const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState(null); @@ -70,44 +72,45 @@ function App() { const [showLockConfirm, setShowLockConfirm] = useState(false); // Krux integration state - const [encryptionMode, setEncryptionMode] = useState<'pgp' | 'krux'>('pgp'); + const [encryptionMode, setEncryptionMode] = useState<'pgp' | 'krux' | 'seedqr'>('pgp'); const [kruxLabel, setKruxLabel] = useState('Seed Backup'); const [kruxIterations, setKruxIterations] = useState(200000); - const [detectedMode, setDetectedMode] = useState<'pgp' | 'krux' | null>(null); + const [seedQrFormat, setSeedQrFormat] = useState<'standard' | 'compact'>('standard'); + const [detectedMode, setDetectedMode] = useState(null); const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password']; const isSensitiveKey = (key: string): boolean => { - const lowerKey = key.toLowerCase(); - return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern)); + const lowerKey = key.toLowerCase(); + return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern)); }; const getStorageItems = (storage: Storage): StorageItem[] => { - const items: StorageItem[] = []; - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); - if (key) { - const value = storage.getItem(key) || ''; - items.push({ - key, - value: value.substring(0, 50) + (value.length > 50 ? '...' : ''), - size: new Blob([value]).size, - isSensitive: isSensitiveKey(key) - }); - } + const items: StorageItem[] = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key) { + const value = storage.getItem(key) || ''; + items.push({ + key, + value: value.substring(0, 50) + (value.length > 50 ? '...' : ''), + size: new Blob([value]).size, + isSensitive: isSensitiveKey(key) + }); } - return items.sort((a, b) => (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0)); + } + return items.sort((a, b) => (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0)); }; const refreshStorage = () => { - setLocalItems(getStorageItems(localStorage)); - setSessionItems(getStorageItems(sessionStorage)); + setLocalItems(getStorageItems(localStorage)); + setSessionItems(getStorageItems(sessionStorage)); }; useEffect(() => { - refreshStorage(); - const interval = setInterval(refreshStorage, 2000); - return () => clearInterval(interval); + refreshStorage(); + const interval = setInterval(refreshStorage, 2000); + return () => clearInterval(interval); }, []); @@ -121,58 +124,56 @@ function App() { useEffect(() => { const handleCopy = (e: ClipboardEvent & Event) => { - const target = e.target as HTMLElement; + const target = e.target as HTMLElement; - // Get selection to measure length - const selection = window.getSelection()?.toString() || ''; - const length = selection.length; + // Get selection to measure length + const selection = window.getSelection()?.toString() || ''; + const length = selection.length; - if (length === 0) return; // Nothing copied + if (length === 0) return; // Nothing copied - // Detect field name - let field = 'Unknown field'; + // Detect field name + let field = 'Unknown field'; - if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') { - // Try multiple ways to identify the field - field = - target.getAttribute('aria-label') || - target.getAttribute('name') || - target.getAttribute('id') || - (target as HTMLInputElement).type || - target.tagName.toLowerCase(); + // Check for data-sensitive attribute on any element first + const sensitiveAttr = target.getAttribute('data-sensitive') || + target.closest('[data-sensitive]')?.getAttribute('data-sensitive'); + if (sensitiveAttr) { + field = sensitiveAttr; + } else if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') { + // Try multiple ways to identify the field for legacy inputs + field = + target.getAttribute('aria-label') || + target.getAttribute('name') || + target.getAttribute('id') || + (target as HTMLInputElement).type || + target.tagName.toLowerCase(); - // Check parent labels - const label = target.closest('label') || - document.querySelector(`label[for="${target.id}"]`); - if (label) { - field = label.textContent?.trim() || field; - } - - // Check for data-sensitive attribute - const sensitiveAttr = target.getAttribute('data-sensitive') || - target.closest('[data-sensitive]')?.getAttribute('data-sensitive'); - if (sensitiveAttr) { - field = sensitiveAttr; - } - - // Detect if it looks like sensitive data - const isSensitive = /mnemonic|seed|key|private|password|secret/i.test( - target.className + ' ' + field + ' ' + (target.getAttribute('placeholder') || '') - ); - - if (isSensitive && field === target.tagName.toLowerCase()) { - // Try to guess from placeholder - const placeholder = target.getAttribute('placeholder'); - if (placeholder) { - field = placeholder.substring(0, 40) + '...'; - } - } + // Check parent labels + const label = target.closest('label') || + document.querySelector(`label[for="${target.id}"]`); + if (label) { + field = label.textContent?.trim() || field; } - setClipboardEvents(prev => [ - { timestamp: new Date(), field, length }, - ...prev.slice(0, 9) // Keep last 10 events - ]); + // Detect if it looks like sensitive data + const isSensitive = /mnemonic|seed|key|private|password|secret/i.test( + target.className + ' ' + field + ' ' + (target.getAttribute('placeholder') || '') + ); + + if (isSensitive && field === target.tagName.toLowerCase()) { + // Try to guess from placeholder + const placeholder = target.getAttribute('placeholder'); + if (placeholder) { + field = placeholder.substring(0, 40) + '...'; + } + } + } + + setClipboardEvents(prev => [ + { timestamp: new Date(), field, length }, + ...prev.slice(0, 9) // Keep last 10 events + ]); }; document.addEventListener('copy', handleCopy as EventListener); @@ -184,8 +185,8 @@ function App() { if (activeTab === 'restore' && restoreInput.trim()) { const detected = detectEncryptionMode(restoreInput); setDetectedMode(detected); - // Auto-switch mode if not already set - if (detected !== encryptionMode) { + // Auto-switch encryption mode if not already set AND it's an encrypted type + if ((detected === 'pgp' || detected === 'krux') && detected !== encryptionMode) { setEncryptionMode(detected); } } else { @@ -194,42 +195,45 @@ function App() { }, [restoreInput, activeTab, encryptionMode]); const clearClipboard = async () => { - try { - // Actually clear the system clipboard - await navigator.clipboard.writeText(''); + try { + // Actually clear the system clipboard + await navigator.clipboard.writeText(''); - // Clear history - setClipboardEvents([]); + // Clear history + setClipboardEvents([]); - // Show success briefly - alert('✅ Clipboard cleared and history wiped'); - } catch (err) { - // Fallback for browsers that don't support clipboard API - const dummy = document.createElement('textarea'); - dummy.value = ''; - document.body.appendChild(dummy); - dummy.select(); - document.execCommand('copy'); - document.body.removeChild(dummy); + // Show success briefly + alert('✅ Clipboard cleared and history wiped'); + } catch (err) { + // Fallback for browsers that don't support clipboard API + const dummy = document.createElement('textarea'); + dummy.value = ''; + document.body.appendChild(dummy); + dummy.select(); + document.execCommand('copy'); + document.body.removeChild(dummy); - setClipboardEvents([]); - alert('✅ History cleared (clipboard may require manual clearing)'); - } + setClipboardEvents([]); + alert('✅ History cleared (clipboard may require manual clearing)'); + } }; - const copyToClipboard = async (text: string) => { + const copyToClipboard = async (text: string | Uint8Array) => { if (isReadOnly) { setError("Copy to clipboard is disabled in Read-only mode."); return; } + + const textToCopy = typeof text === 'string' ? text : Array.from(text).map(b => b.toString(16).padStart(2, '0')).join(''); + try { - await navigator.clipboard.writeText(text); + await navigator.clipboard.writeText(textToCopy); setCopied(true); window.setTimeout(() => setCopied(false), 1500); } catch { const ta = document.createElement("textarea"); - ta.value = text; + ta.value = textToCopy; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); @@ -256,22 +260,42 @@ function App() { const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase); - const result = await encryptToSeed({ - plaintext, - publicKeyArmored: publicKeyInput || undefined, - messagePassword: backupMessagePassword || undefined, - mode: encryptionMode, - kruxLabel: encryptionMode === 'krux' ? kruxLabel : undefined, - kruxIterations: encryptionMode === 'krux' ? kruxIterations : undefined, - }); + let result: EncryptionResult; + if (encryptionMode === 'seedqr') { + if (seedQrFormat === 'standard') { + const qrString = await encodeStandardSeedQR(mnemonic); + console.log('📋 Standard SeedQR generated:', qrString.slice(0, 50) + '...'); + result = { framed: qrString }; + } else { // compact + const qrEntropy = await encodeCompactSeedQREntropy(mnemonic); + + console.log('🔐 Compact SeedQR generated:'); + console.log(' - Type:', qrEntropy instanceof Uint8Array ? 'Uint8Array' : typeof qrEntropy); + console.log(' - Length:', qrEntropy.length); + console.log(' - Hex:', Array.from(qrEntropy).map(b => b.toString(16).padStart(2, '0')).join('')); + console.log(' - First 16 bytes:', Array.from(qrEntropy.slice(0, 16))); + + result = { framed: qrEntropy }; // framed will hold the Uint8Array + } + } else { + // Existing PGP and Krux encryption + result = await encryptToSeed({ + plaintext, + publicKeyArmored: publicKeyInput || undefined, + messagePassword: backupMessagePassword || undefined, + mode: encryptionMode, + kruxLabel: encryptionMode === 'krux' ? kruxLabel : undefined, + kruxIterations: encryptionMode === 'krux' ? kruxIterations : undefined, + }); + } setQrPayload(result.framed); if (result.recipientFingerprint) { setRecipientFpr(result.recipientFingerprint); } - // Initialize session key before encrypting - await getSessionKey(); + // Initialize session key before encrypting + await getSessionKey(); // Encrypt mnemonic with session key and clear plaintext state const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() }); setEncryptedMnemonicCache(blob); @@ -283,6 +307,7 @@ function App() { } }; + const handleRestore = async () => { setLoading(true); setError(''); @@ -291,7 +316,7 @@ function App() { try { // Auto-detect mode if not manually set const modeToUse = detectedMode || encryptionMode; - + const result = await decryptFromSeed({ frameText: restoreInput, privateKeyArmored: privateKeyInput || undefined, @@ -363,13 +388,35 @@ function App() { } }; + const handleRestoreScanSuccess = useCallback((scannedData: string | Uint8Array) => { + if (typeof scannedData === 'string') { + setRestoreInput(scannedData); + } else { + const hex = Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join(''); + setRestoreInput(hex); + } + setShowQRScanner(false); + setError(''); + }, []); // Empty dependency array means this function is stable + + const handleRestoreClose = useCallback(() => { + setShowQRScanner(false); + }, []); + + const handleRestoreError = useCallback((error: string) => { + setError(error); + }, []); + + + + return (
-
setShowSecurityModal(true)} - localItems={localItems} - sessionItems={sessionItems} +
setShowSecurityModal(true)} + localItems={localItems} + sessionItems={sessionItems} onOpenStorageModal={() => setShowStorageModal(true)} events={clipboardEvents} onOpenClipboardModal={() => setShowClipboardModal(true)} @@ -383,353 +430,365 @@ function App() { />
- {/* Error Display */} - {error && ( -
- -
-

Error

-

{error}

-
-
- )} - - {/* Info Banner */} - {recipientFpr && activeTab === 'backup' && ( -
- -
- Recipient Key: {recipientFpr} -
-
- )} - - {/* Main Content Grid */} -
-
- {activeTab === 'backup' ? ( - <> -
- -