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(