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", () => {