Skip to content

Commit

Permalink
Add base58check support
Browse files Browse the repository at this point in the history
  • Loading branch information
r-n-o committed Nov 20, 2023
1 parent 1adaa70 commit d8593a5
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 3 deletions.
82 changes: 80 additions & 2 deletions recovery/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ <h2>Message log</h2>
}

/**
* Encodes a buffer into base64url
* Decodes a base64-encoded string into a buffer
* @param {string} s
* @return {Uint8Array}
*/
Expand All @@ -262,6 +262,64 @@ <h2>Message log</h2>
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 result = [];
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.
// "result.length == i" can only happen if we have not seen non-zero bytes so far
if (carry == 0 && result.length === i) {
result.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)
currentByte = currentByte ? currentByte * 58 + carry : 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++
}
}

result = result.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
Expand Down Expand Up @@ -764,6 +822,7 @@ <h2>Message log</h2>
logMessage,
base64urlEncode,
base64urlDecode,
base58checkDecode,
bigIntToHex,
stringToBase64urlString,
uint8arrayToHexString,
Expand Down Expand Up @@ -854,7 +913,26 @@ <h2>Message log</h2>
* @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) + 80 (encrypted cred) = 113 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 113 bytes string being in the overlap character set for its entire length is...
// ... 0.90625^113 = 0.00001475795604
// Are you convinced that this is good enough? I am :)
var bundleBytes = 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")
}
Expand Down
23 changes: 22 additions & 1 deletion recovery/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 80-bytes long encrypted credential
// Test vector from our internal repo, which uses Rust to encode in base58check.
expect(Array.from(await TKHQ.base58checkDecode("Mobo835D8oQBX4BWPSrtYFcVHFNGgsp1X14t1MM18QpZD3aJdZJ4MioQk6ChU2mZ6b7gM3RxyiV5ArnwK2TH8bTU19zNG29q4w9WbBEp8HWuJLYqBTCh3KJPbnCxVcvDdhHZQ5nmghUB7noTXLTXeu3nnHbnuEz"))).toEqual(
Array.from(TKHQ.uint8arrayFromHexString("03d61d659ab8485f30cfe261ff965179519b2aeb16223ccc217e99b09d5aeb94f1ce9e701341d6ab5b330bf39a3488dfe37a7fc0d04b556de1f7c4beaf4f3c131c2fbb5e28e9c3056d621b66a9bb0dac7c11759767c3ff10ca0f686a06c4a30b6e57902fadc9a2a840cf1356592220fc80"))
);
})

it("contains uint8arrayToHexString", () => {
Expand Down

0 comments on commit d8593a5

Please sign in to comment.