diff --git a/export/index.template.html b/export/index.template.html index 98ad026..08e4d74 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -178,6 +178,233 @@

Message log

/** 48 hours in milliseconds */ const TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS = 1000 * 60 * 60 * 48; + const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + + // Map char -> value + const BECH32_CHAR_MAP = (() => { + const map = {}; + for (let i = 0; i < BECH32_CHARSET.length; i++) { + map[BECH32_CHARSET[i]] = i; + } + return map; + })(); + + /** + * Internal function that computes the Bech32 checksum. + * @param {number[]} values + * @returns {number} + */ + function bech32Polymod(values) { + let chk = 1; + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + for (let p = 0; p < values.length; p++) { + const v = values[p]; + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) { + if (((top >>> i) & 1) !== 0) { + chk ^= GEN[i]; + } + } + } + return chk; + } + + /** + * Expand the HRP for Bech32 checksum computation. + * @param {string} hrp + * @returns {number[]} + */ + function bech32HrpExpand(hrp) { + const ret = []; + for (let i = 0; i < hrp.length; i++) { + const c = hrp.charCodeAt(i); + ret.push(c >> 5); + } + ret.push(0); + for (let i = 0; i < hrp.length; i++) { + const c = hrp.charCodeAt(i); + ret.push(c & 31); + } + return ret; + } + + /** + * Create a Bech32 checksum. + * @param {string} hrp + * @param {number[]} data + * @returns {number[]} + */ + function bech32CreateChecksum(hrp, data) { + const values = bech32HrpExpand(hrp).concat(data); + const polymod = bech32Polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ 1; // bech32 const = 1 + const checksum = []; + for (let i = 0; i < 6; i++) { + checksum.push((polymod >> (5 * (5 - i))) & 31); + } + return checksum; + } + + /** + * Convert from 8-bit bytes to 5-bit words. + * @param {Uint8Array} bytes + * @returns {number[]} + */ + function bech32ToWords(bytes) { + const fromBits = 8; + const toBits = 5; + const pad = true; + + let acc = 0; + let bits = 0; + const maxv = (1 << toBits) - 1; + const result = []; + + for (let i = 0; i < bytes.length; i++) { + const value = bytes[i]; + if (value < 0 || value > 255) { + throw new Error("invalid byte value for bech32"); + } + acc = (acc << fromBits) | value; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >> bits) & maxv); + } + } + + if (pad) { + if (bits > 0) { + result.push((acc << (toBits - bits)) & maxv); + } + } else { + if (bits >= fromBits) { + throw new Error("excess padding in bech32 data"); + } + if ((acc & ((1 << bits) - 1)) !== 0) { + throw new Error("non-zero padding in bech32 data"); + } + } + + return result; + } + + /** + * Convert from 5-bit words to 8-bit bytes. + * @param {number[]} words + * @returns {Uint8Array} + */ + function bech32FromWords(words) { + const fromBits = 5; + const toBits = 8; + const pad = false; + + let acc = 0; + let bits = 0; + const maxv = (1 << toBits) - 1; + const maxAcc = (1 << (fromBits + toBits - 1)) - 1; + const result = []; + + for (let i = 0; i < words.length; i++) { + const value = words[i]; + if (value < 0 || (value >> fromBits) !== 0) { + throw new Error("invalid bech32 word"); + } + acc = ((acc << fromBits) | value) & maxAcc; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >> bits) & maxv); + } + } + + if (pad) { + if (bits > 0) { + result.push((acc << (toBits - bits)) & maxv); + } + } else { + if (bits >= fromBits) { + throw new Error("excess padding in bech32 data"); + } + if ((acc & ((1 << bits) - 1)) !== 0) { + throw new Error("non-zero padding in bech32 data"); + } + } + + return new Uint8Array(result); + } + + /** + * Encode bytes into a Bech32 string. + * @param {string} hrp + * @param {Uint8Array} bytes + * @returns {string} + */ + function encodeBech32(hrp, bytes) { + const words = bech32ToWords(bytes); + const checksum = bech32CreateChecksum(hrp, words); + const combined = words.concat(checksum); + + let out = hrp + "1"; + for (let i = 0; i < combined.length; i++) { + out += BECH32_CHARSET[combined[i]]; + } + return out; + } + + /** + * Strict decode: accepts bech32 or bech32m. + * @param {string} str + * @returns {{hrp: string, words: number[], variant: string}} + */ + function decodeBech32(str) { + // Enforce lower-case (Sui uses lowercase) + const s = str.toLowerCase(); + + // Split HRP and data at last '1' + const pos = s.lastIndexOf("1"); + if (pos <= 0 || pos + 7 > s.length) { + throw new Error("invalid bech32: bad separator or too short"); + } + + const hrp = s.slice(0, pos); + const dataPart = s.slice(pos + 1); + + if (!hrp || hrp.length < 1) { + throw new Error("invalid bech32: HRP too short"); + } + + const data = []; + for (let i = 0; i < dataPart.length; i++) { + const c = dataPart[i]; + const v = BECH32_CHAR_MAP[c]; + if (v == null) { + throw new Error(`invalid bech32 character: '${c}'`); + } + data.push(v); + } + + if (data.length < 6) { + throw new Error("invalid bech32: data too short (no room for checksum)"); + } + + const values = bech32HrpExpand(hrp).concat(data); + const polymod = bech32Polymod(values); + + // bech32 checksum = 1, bech32m checksum = 0x2bc830a3 + const BECH32_CONST = 1; + const BECH32M_CONST = 0x2bc830a3; + + if (polymod !== BECH32_CONST && polymod !== BECH32M_CONST) { + throw new Error("invalid bech32: checksum mismatch"); + } + + // strip checksum (last 6 words) + const words = data.slice(0, data.length - 6); + + return { hrp, words, variant: polymod === BECH32_CONST ? "bech32" : "bech32m" }; + } + var parentFrameMessageChannelPort = null; /* @@ -593,6 +820,29 @@

Message log

return result; } + /** + * Decodes a base58-check-encoded string into a buffer + * This function throws an error when the string contains invalid characters + * or the checksum is invalid. + * @param {string} s The base58-check-encoded string. + * @return {Uint8Array} The decoded buffer. + */ + async function base58CheckEncode(payload) { + const hash1Buf = await crypto.subtle.digest("SHA-256", payload); + const hash1 = new Uint8Array(hash1Buf); + + const hash2Buf = await crypto.subtle.digest("SHA-256", hash1); + const hash2 = new Uint8Array(hash2Buf); + + const checksum = hash2.slice(0, 4); + + const full = new Uint8Array(payload.length + 4); + full.set(payload, 0); + full.set(checksum, payload.length); + + return base58Encode(full); + } + /** * Decodes a base58-encoded string into a buffer * This function throws an error when the string contains invalid characters. @@ -650,7 +900,7 @@

Message log

* the encoding and format specified by `keyFormat`. Defaults to * hex-encoding if `keyFormat` isn't passed. * @param {Uint8Array} privateKeyBytes - * @param {string} keyFormat Can be "HEXADECIMAL" or "SOLANA" + * @param {string} keyFormat Can be "HEXADECIMAL", "SUI_BECH32", "BITCOIN_WIF" or "SOLANA" */ async function encodeKey(privateKeyBytes, keyFormat, publicKeyBytes) { switch (keyFormat) { @@ -676,6 +926,33 @@

Message log

return base58Encode(concatenatedBytes); case "HEXADECIMAL": return "0x" + uint8arrayToHexString(privateKeyBytes); + case "BITCOIN_WIF": + if (privateKeyBytes.length !== 32) { + throw new Error( + `invalid private key length. Expected 32 bytes. Got ${privateKeyBytes.length}.` + ); + } + + const version = 0x80 // mainnet version byte TODO: support testnet? how would we know which to use? + const wifPayload = new Uint8Array(1 + 32 + 1); + wifPayload[0] = version; + wifPayload.set(privateKeyBytes, 1); + wifPayload[33] = 0x01 // compressed flag + + return await base58CheckEncode(wifPayload); + case "SUI_BECH32": + if (privateKeyBytes.length !== 32) { + throw new Error( + `invalid private key length. Expected 32 bytes. Got ${privateKeyBytes.length}.` + ); + } + + const schemeFlag = 0x00; // ED25519 | We only support ED25519 keys for Sui currently + const bech32Payload = new Uint8Array(1 + 32); + bech32Payload[0] = schemeFlag; + bech32Payload.set(privateKeyBytes, 1); + + return encodeBech32("suiprivkey", bech32Payload); default: console.warn( `invalid key format: ${keyFormat}. Defaulting to HEXADECIMAL.` @@ -842,7 +1119,10 @@

Message log

p256JWKPrivateToPublic, base58Encode, base58Decode, + decodeBech32, + bech32FromWords, encodeKey, + encodeBech32, encodeWallet, sendMessageUp, logMessage, diff --git a/export/index.test.js b/export/index.test.js index abe9230..e5369d0 100644 --- a/export/index.test.js +++ b/export/index.test.js @@ -120,6 +120,25 @@ describe("TKHQ", () => { expect(encodedKey).toEqual(keySol); }); + it("encodes bitcoin WIF private key correctly", async () => { + const keyWif = "L1sF5SF3CnCN9gA7vh7MAtbiVu9igdr3C1BYPKZduw4yaezdeCTV"; + const keyWifBytes = (TKHQ.base58Decode(keyWif)).subarray(0, -4); // remove 4 byte checksum + expect(keyWifBytes.length).toEqual(34); // 1 byte version + 32 byte privkey + 1 byte compressed flag + const keyPrivBytes = keyWifBytes.subarray(1, 33); + const encodedKey = await TKHQ.encodeKey(keyPrivBytes, "BITCOIN_WIF"); + expect(encodedKey).toEqual(keyWif); + }); + + it("encodes sui bech32 private key correctly", async () => { + const keySui = + "suiprivkey1qpj5xd9396rxsu7h45tzccalhuf95e4pygls3ps9txszn9ywpwsnznaeq0l"; + const { _, words} = TKHQ.decodeBech32(keySui); + const keySuiBytes = (TKHQ.bech32FromWords(words)).subarray(1); // remove 1 byte scheme flag + expect(keySuiBytes.length).toEqual(32); + const encodedKey = await TKHQ.encodeKey(keySuiBytes, "SUI_BECH32"); + expect(encodedKey).toEqual(keySui); + }); + it("encodes wallet with only mnemonic correctly", async () => { const mnemonic = "suffer surround soup duck goose patrol add unveil appear eye neglect hurry alpha project tomorrow embody hen wish twenty join notable amused burden treat"; diff --git a/import/index.template.html b/import/index.template.html index dfce787..97e0686 100644 --- a/import/index.template.html +++ b/import/index.template.html @@ -46,6 +46,156 @@ const TURNKEY_TARGET_EMBEDDED_KEY = "TURNKEY_TARGET_EMBEDDED_KEY"; const TURNKEY_SETTINGS = "TURNKEY_SETTINGS"; + // Bech32 character set (standard BIP-0173) + const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + + // Map char -> value + const BECH32_CHAR_MAP = (() => { + const map = {}; + for (let i = 0; i < BECH32_CHARSET.length; i++) { + map[BECH32_CHARSET[i]] = i; + } + return map; + })(); + + /** + * Expand the HRP for Bech32 checksum computation. + * @param {string} hrp + * @returns {number[]} + */ + function bech32HrpExpand(hrp) { + const ret = []; + for (let i = 0; i < hrp.length; i++) { + const c = hrp.charCodeAt(i); + ret.push(c >> 5); + } + ret.push(0); + for (let i = 0; i < hrp.length; i++) { + const c = hrp.charCodeAt(i); + ret.push(c & 31); + } + return ret; + } + + /** + * Compute the Bech32 checksum. + * @param {number[]} values + * @returns {number} + */ + function bech32Polymod(values) { + let chk = 1; + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + for (let p = 0; p < values.length; p++) { + const v = values[p]; + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) { + if (((top >>> i) & 1) !== 0) { + chk ^= GEN[i]; + } + } + } + return chk; + } + + /** + * Strict decode: accepts bech32 or bech32m. + * @param {string} str + * @returns {{hrp: string, words: number[], variant: string}} + */ + function decodeBech32(str) { + // Enforce lower-case (Sui uses lowercase) + const s = str.toLowerCase(); + + // Split HRP and data at last '1' + const pos = s.lastIndexOf("1"); + if (pos <= 0 || pos + 7 > s.length) { + throw new Error("invalid bech32: bad separator or too short"); + } + + const hrp = s.slice(0, pos); + const dataPart = s.slice(pos + 1); + + if (!hrp || hrp.length < 1) { + throw new Error("invalid bech32: HRP too short"); + } + + const data = []; + for (let i = 0; i < dataPart.length; i++) { + const c = dataPart[i]; + const v = BECH32_CHAR_MAP[c]; + if (v == null) { + throw new Error(`invalid bech32 character: '${c}'`); + } + data.push(v); + } + + if (data.length < 6) { + throw new Error("invalid bech32: data too short (no room for checksum)"); + } + + const values = bech32HrpExpand(hrp).concat(data); + const polymod = bech32Polymod(values); + + // bech32 checksum = 1, bech32m checksum = 0x2bc830a3 + const BECH32_CONST = 1; + const BECH32M_CONST = 0x2bc830a3; + + if (polymod !== BECH32_CONST && polymod !== BECH32M_CONST) { + throw new Error("invalid bech32: checksum mismatch"); + } + + // strip checksum (last 6 words) + const words = data.slice(0, data.length - 6); + + return { hrp, words, variant: polymod === BECH32_CONST ? "bech32" : "bech32m" }; + } + + /** + * Convert from 5-bit words to 8-bit bytes. + * @param {number[]} words + * @returns {Uint8Array} + */ + function bech32FromWords(words) { + const fromBits = 5; + const toBits = 8; + const pad = false; + + let acc = 0; + let bits = 0; + const maxv = (1 << toBits) - 1; + const maxAcc = (1 << (fromBits + toBits - 1)) - 1; + const result = []; + + for (let i = 0; i < words.length; i++) { + const value = words[i]; + if (value < 0 || (value >> fromBits) !== 0) { + throw new Error("invalid bech32 word"); + } + acc = ((acc << fromBits) | value) & maxAcc; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >> bits) & maxv); + } + } + + if (pad) { + if (bits > 0) { + result.push((acc << (toBits - bits)) & maxv); + } + } else { + if (bits >= fromBits) { + throw new Error("excess padding in bech32 data"); + } + if ((acc & ((1 << bits) - 1)) !== 0) { + throw new Error("non-zero padding in bech32 data"); + } + } + + return new Uint8Array(result); + } + var parentFrameMessageChannelPort = null; /* @@ -216,7 +366,7 @@ * the encoding and format specified by `keyFormat`. Defaults to * hex-encoding if `keyFormat` isn't passed. * @param {string} privateKey - * @param {string} keyFormat Can be "HEXADECIMAL" or "SOLANA" + * @param {string} keyFormat Can be "HEXADECIMAL", "WIF", "BECH32" or "SOLANA" */ function decodeKey(privateKey, keyFormat) { switch (keyFormat) { @@ -228,6 +378,52 @@ ); } return decodedKeyBytes.subarray(0, 32); + case "BITCOIN_WIF": + const payload = base58Decode(privateKey) + + const version = payload[0]; + const keyAndFlags = payload.subarray(1, payload.length - 4); + + // 0x80 = mainnet, 0xEF = testnet + if (version !== 0x80 && version !== 0xef) { + throw new Error( + `invalid WIF version byte: ${version}. Expected 0x80 (mainnet) or 0xEF (testnet).` + ); + } + + if (keyAndFlags.length === 32) { + throw new Error( + "uncompressed WIF not supported; please use a compressed WIF key" + ) + } + + if (keyAndFlags.length === 33 && keyAndFlags[32] === 0x01) { + return keyAndFlags.subarray(0, 32); + } + + throw new Error ("invalid WIF payload format"); + case "SUI_BECH32": + const { hrp, words } = decodeBech32(privateKey); + + if (hrp !== "suiprivkey") { + throw new Error( + `invalid SUI private key HRP: expoected "suiprivkey", got "${hrp}"` + ); + } + + const bytes = Uint8Array.from(bech32FromWords(words)); + if (bytes.length !== 33) { + throw new Error( + `invalid SUI private key length: expected 33 bytes, got ${bytes.length}` + ); + } + + const schemeFlag = bytes[0]; + const privkey = bytes.subarray(1); + + // TODO: schemeFlag = 0 is Ed25519; figure out if we need to support dynamic curve detection or something + + return privkey; case "HEXADECIMAL": if (privateKey.startsWith("0x")) { return uint8arrayFromHexString(privateKey.slice(2)); @@ -549,6 +745,8 @@ uint8arrayFromHexString, uint8arrayToHexString, base58Decode, + decodeBech32, + bech32FromWords, decodeKey, setParentFrameMessageChannelPort, normalizePadding, @@ -872,7 +1070,7 @@ if (!plaintext) { throw new Error("no private key entered"); } - const plaintextBuf = TKHQ.decodeKey(plaintext, keyFormat); + const plaintextBuf = await TKHQ.decodeKey(plaintext, keyFormat); // Encrypt the bundle using the enclave target public key const encryptedBundle = await HpkeEncrypt({ diff --git a/import/index.test.js b/import/index.test.js index 5df94d7..fdfc4c0 100644 --- a/import/index.test.js +++ b/import/index.test.js @@ -89,6 +89,30 @@ describe("TKHQ", () => { } }); + it("decodes bitcoin wif private key correctly", async () => { + const keyBtcWif = "L1sF5SF3CnCN9gA7vh7MAtbiVu9igdr3C1BYPKZduw4yaezdeCTV"; + const keyBytes = TKHQ.base58Decode(keyBtcWif); + expect(keyBytes.length).toBeGreaterThan(32); + const keyPrivBytes = keyBytes.subarray(1, 33); // Remove version byte at start and compression flag at end + const decodedKey = TKHQ.decodeKey(keyBtcWif, "BITCOIN_WIF"); + expect(decodedKey.length).toEqual(keyPrivBytes.length); + for (let i = 0; i < decodedKey.length; i++) { + expect(decodedKey[i]).toEqual(keyPrivBytes[i]); + } + }); + + it ("decodes sui bech32 private key correctly", async () => { + const keySuiBech32 = "suiprivkey1qpj5xd9396rxsu7h45tzccalhuf95e4pygls3ps9txszn9ywpwsnznaeq0l"; + const {_, words } = TKHQ.decodeBech32(keySuiBech32); + const keyBytes = Uint8Array.from(TKHQ.bech32FromWords(words)).subarray(1); // Remove version byte at start + expect(keyBytes.length).toEqual(32); + const decodedKey = TKHQ.decodeKey(keySuiBech32, "SUI_BECH32"); + expect(decodedKey.length).toEqual(keyBytes.length); + for (let i = 0; i < decodedKey.length; i++) { + expect(decodedKey[i]).toEqual(keyBytes[i]); + } + }); + it("contains additionalAssociatedData", async () => { // This is a trivial helper; concatenates the 2 arrays! expect(