Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add base58check decoding to recovery iframe #18

Merged
merged 4 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18.18.2
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ git clone [email protected]: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
Expand Down
90 changes: 88 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,72 @@ <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 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
Expand Down Expand Up @@ -764,6 +830,7 @@ <h2>Message log</h2>
logMessage,
base64urlEncode,
base64urlDecode,
base58checkDecode,
bigIntToHex,
stringToBase64urlString,
uint8arrayToHexString,
Expand Down Expand Up @@ -854,7 +921,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) + 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

// 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")
}
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 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", () => {
Expand Down
Loading