diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..87ec884
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+18.18.2
diff --git a/README.md b/README.md
index 93b6ee5..70edd09 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,13 @@ git clone git@github.com:tkhq/frames.git
cd frames/
```
-Install dependencies
+Install Node:
+```sh
+nvm use
+```
+(the command above installs the version specified in `.nvmrc`, but any Node version >= v18 should do)
+
+Install dependencies:
```sh
cd recovery && npm install
cd export && npm install
diff --git a/recovery/index.html b/recovery/index.html
index 46e82b9..78407b0 100644
--- a/recovery/index.html
+++ b/recovery/index.html
@@ -249,7 +249,7 @@
Message log
}
/**
- * Encodes a buffer into base64url
+ * Decodes a base64-encoded string into a buffer
* @param {string} s
* @return {Uint8Array}
*/
@@ -262,6 +262,72 @@ Message log
return bytes;
}
+ /**
+ * Decodes a base58check-encoded string into a buffer
+ * This function throws an error when the string is too small, doesn't have the proper checksum, or contains invalid characters.
+ * Inspired by https://gist.github.com/diafygi/90a3e80ca1c2793220e5/
+ * @param {string} s
+ * @return {Uint8Array}
+ */
+ async function base58checkDecode(s) {
+ if (s.length < 5) {
+ throw new Error(`cannot base58-decode a string of length < 5 (found length ${s.length})`)
+ }
+
+ // See https://en.bitcoin.it/wiki/Base58Check_encoding
+ var alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+ var decoded = BigInt(0);
+ var decodedBytes = [];
+ var leadingZeros = [];
+ for (var i = 0; i < s.length; i++) {
+ if (alphabet.indexOf(s[i]) === -1) {
+ throw new Error(`cannot base58-decode: ${s[i]} isn't a valid character`)
+ }
+ var carry = alphabet.indexOf(s[i]);
+
+ // If the current base58 digit is 0, append a 0 byte.
+ // "i == leadingZeros.length" can only be true if we have not seen non-zero bytes so far.
+ // If we had seen a non-zero byte, carry wouldn't be 0, and i would be strictly more than `leadingZeros.length`
+ if (carry == 0 && i === leadingZeros.length) {
+ leadingZeros.push(0);
+ }
+
+ var j = 0;
+ while (j < decodedBytes.length || carry > 0) {
+ var currentByte = decodedBytes[j];
+
+ // shift the current byte 58 units and add the carry amount
+ // (or just add the carry amount if this is a new byte -- undefined case)
+ if (currentByte === undefined) {
+ currentByte = carry
+ } else {
+ currentByte = currentByte * 58 + carry
+ }
+
+ // find the new carry amount (1-byte shift of current byte value)
+ carry = currentByte >> 8;
+ // reset the current byte to the remainder (the carry amount will pass on the overflow)
+ decodedBytes[j] = currentByte % 256;
+ j++
+ }
+ }
+
+ var result = leadingZeros.concat(decodedBytes.reverse());
+
+ var foundChecksum = result.slice(result.length - 4)
+
+ var msg = result.slice(0, result.length - 4)
+ var checksum1 = await crypto.subtle.digest("SHA-256", new Uint8Array(msg))
+ var checksum2 = await crypto.subtle.digest("SHA-256", new Uint8Array(checksum1))
+ var computedChecksum = Array.from(new Uint8Array(checksum2)).slice(0, 4);
+
+ if (computedChecksum.toString() != foundChecksum.toString()) {
+ throw new Error(`checksums do not match: computed ${computedChecksum} but found ${foundChecksum}`)
+ }
+
+ return new Uint8Array(msg);
+ }
+
/**
* `SubtleCrypto.sign(...)` outputs signature in IEEE P1363 format:
* - https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign#ecdsa
@@ -764,6 +830,7 @@ Message log
logMessage,
base64urlEncode,
base64urlDecode,
+ base58checkDecode,
bigIntToHex,
stringToBase64urlString,
uint8arrayToHexString,
@@ -854,7 +921,26 @@ Message log
* @param {string} bundle
*/
var onInjectBundle = async function(bundle) {
- var bundleBytes = TKHQ.base64urlDecode(bundle);
+ if (
+ // Non-alphanumerical characters in base64url: - and _. These aren't in base58.
+ bundle.indexOf("-") === -1 && bundle.indexOf("_") === -1
+ // Uppercase o (O), uppercase i (I), lowercase L (l), and 0 aren't in the character set either.
+ && bundle.indexOf("O") === -1 && bundle.indexOf("I") === -1 && bundle.indexOf("l") === -1 && bundle.indexOf("0") === -1
+ ) {
+ // If none of these characters are in the bundle we assume it's a base58check-encoded string
+ // This isn't perfect: there's a small chance that a base64url-encoded string doesn't have any of these characters by chance!
+ // But we accept this risk given this branching is only here to support our transition to base58check.
+ // I hear you'd like to quantify this risk? Let's do it.
+ // Assuming random bytes in our bundle and a bundle length of 33 (public key, compressed) + 48 (encrypted cred) = 81 bytes.
+ // The odds of a byte being in the overlap set between base58 and base64url is 58/64=0.90625.
+ // Which means the odds of a 81 bytes string being in the overlap character set for its entire length is...
+ // ... 0.90625^81 = 0.0003444209703
+ // Are you convinced that this is good enough? I am :)
+ var bundleBytes = await TKHQ.base58checkDecode(bundle);
+ } else {
+ var bundleBytes = TKHQ.base64urlDecode(bundle);
+ }
+
if (bundleBytes.byteLength <= 33) {
throw new Error("bundle size " + bundleBytes.byteLength + " is too low. Expecting a compressed public key (33 bytes) and an encrypted credential")
}
diff --git a/recovery/index.test.js b/recovery/index.test.js
index 7961a6e..941cb27 100644
--- a/recovery/index.test.js
+++ b/recovery/index.test.js
@@ -131,7 +131,28 @@ describe("TKHQ", () => {
})
it("contains base64urlDecode", () => {
- expect(TKHQ.base64urlDecode("AQID").buffer).toEqual(new Uint8Array([1, 2, 3]).buffer);
+ expect(Array.from(TKHQ.base64urlDecode("AQID"))).toEqual([1, 2, 3]);
+ })
+
+ it("contains base58checkDecode", async () => {
+ await expect(TKHQ.base58checkDecode("N0PE")).rejects.toThrow("cannot base58-decode a string of length < 5 (found length 4)");
+ await expect(TKHQ.base58checkDecode("NOOOO")).rejects.toThrow("cannot base58-decode: O isn't a valid character");
+
+ // Satoshi's Bitcoin address
+ expect(Array.from(await TKHQ.base58checkDecode("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"))).toEqual(
+ // Note: checksum is missing from this expected value since we chop the checksum as part of decoding.
+ // Decoded value on http://lenschulwitz.com/base58 has C29B7D93 (4 bytes) at the end, that's expected and normal.
+ Array.from(TKHQ.uint8arrayFromHexString("0062E907B15CBF27D5425399EBF6F0FB50EBB88F18"))
+ );
+
+ // Same input as above, except last digit changed.
+ await expect(TKHQ.base58checkDecode("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb")).rejects.toThrow("checksums do not match: computed 194,155,125,147 but found 194,155,125,148");
+
+ // Realistic recovery code: concatenation of a 33 bytes P-256 public key + a 48-bytes long encrypted credential
+ // Test vector from our internal repo, which uses Rust to encode in base58check.
+ expect(Array.from(await TKHQ.base58checkDecode("szrFBNGDkhXyVvRoqjjDT6xd7kRhDXHmtQH3NVkPuVVkeiPFjn6UkyjbiTzuxH9wKH4QdEJUaWxZLM1ZLzByUFN1TNjxVh5aoZENCnKYrSEdZBnRWcK"))).toEqual(
+ Array.from(TKHQ.uint8arrayFromHexString("02cb30f1f44d411383cc2a7bb7135d87e0fbf265d0e002b460c9d38d97b14cd0d26114254d213cd77887293644d942a62516a3f174f01ed1ccb57dea1f8ac88664759bb6febcd8b060e7a11d23c614dd66"))
+ );
})
it("contains uint8arrayToHexString", () => {