Skip to content

Commit

Permalink
feedback: move+consolidate logic
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewkmin committed Aug 14, 2024
1 parent 28b3cac commit c6d7a1e
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 77 deletions.
28 changes: 23 additions & 5 deletions packages/api-key-stamper/src/tink/elliptic_curves.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
*/

import * as Bytes from "./bytes";
import {
DEFAULT_JWK_MEMBER_BYTE_LENGTH,
uint8ArrayFromHexString,
} from "@turnkey/encoding";

/**
* P-256 only
Expand Down Expand Up @@ -34,12 +38,20 @@ function byteArrayToInteger(bytes: Uint8Array): bigint {
return BigInt("0x" + Bytes.toHex(bytes));
}

/** Converts bigint to byte array. */
function integerToByteArray(i: bigint): Uint8Array {
/** Converts bigint to byte array. This implementation has been modified to optionally augment the resulting byte array to a certain length. */
function integerToByteArray(i: bigint, length?: number): Uint8Array {
let input = i.toString(16);
// If necessary, prepend leading zero to ensure that input length is even.
input = input.length % 2 === 0 ? input : "0" + input;
return Bytes.fromHex(input);
if (!length) {
return Bytes.fromHex(input);
}
if (input.length / 2 > length) {
throw new Error(
"hex value cannot fit in a buffer of " + length + " byte(s)"
);
}
return uint8ArrayFromHexString(input, length);
}

/** Returns true iff the ith bit (in lsb order) of n is set. */
Expand Down Expand Up @@ -151,8 +163,14 @@ export function pointDecode(point: Uint8Array): JsonWebKey {
const result: JsonWebKey = {
kty: "EC",
crv: "P-256",
x: Bytes.toBase64(integerToByteArray(x), /* websafe */ true),
y: Bytes.toBase64(integerToByteArray(y), /* websafe */ true),
x: Bytes.toBase64(
integerToByteArray(x, DEFAULT_JWK_MEMBER_BYTE_LENGTH),
/* websafe */ true
),
y: Bytes.toBase64(
integerToByteArray(y, DEFAULT_JWK_MEMBER_BYTE_LENGTH),
/* websafe */ true
),
ext: true,
};
return result;
Expand Down
77 changes: 7 additions & 70 deletions packages/api-key-stamper/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,23 @@
import { pointDecode } from "./tink/elliptic_curves";
import {
stringToBase64urlString,
base64urlToBuffer,
uint8ArrayToHexString,
hexStringToBase64url,
uint8ArrayFromHexString,
DEFAULT_JWK_MEMBER_BYTE_LENGTH,
} from "@turnkey/encoding";

const DEFAULT_JWK_MEMBER_BYTE_LENGTH = 32;

export function convertTurnkeyApiKeyToJwk(input: {
uncompressedPrivateKeyHex: string;
compressedPublicKeyHex: string;
}): JsonWebKey {
const { uncompressedPrivateKeyHex, compressedPublicKeyHex } = input;

const jwk = pointDecode(hexStringToUint8Array(compressedPublicKeyHex));

// First make a copy to manipulate
const jwkCopy = { ...jwk };
const jwk = pointDecode(uint8ArrayFromHexString(compressedPublicKeyHex));

// Ensure that each of the constituent parts are sufficiently padded
const paddedD = hexStringToBase64urlString(
// Ensure that d is sufficiently padded
jwk.d = hexStringToBase64url(
uncompressedPrivateKeyHex,
DEFAULT_JWK_MEMBER_BYTE_LENGTH
);

// Manipulate x and y
const decodedX = base64urlToBuffer(jwkCopy.x!);
const paddedX = hexStringToBase64urlString(
uint8ArrayToHexString(new Uint8Array(decodedX)),
DEFAULT_JWK_MEMBER_BYTE_LENGTH
);

const decodedY = base64urlToBuffer(jwkCopy.y!);
const paddedY = hexStringToBase64urlString(
uint8ArrayToHexString(new Uint8Array(decodedY)),
DEFAULT_JWK_MEMBER_BYTE_LENGTH
);

jwkCopy.d = paddedD;
jwkCopy.x = paddedX;
jwkCopy.y = paddedY;

return jwkCopy;
}

/*
* Note: the following helpers will soon be moved to @tkhq/encoding
*/
function hexStringToUint8Array(input: string, length?: number): Uint8Array {
if (
input.length === 0 ||
input.length % 2 !== 0 ||
/[^a-fA-F0-9]/u.test(input)
) {
throw new Error(`Invalid hex string: ${JSON.stringify(input)}`);
}

const buffer = Uint8Array.from(
input
.match(
/.{2}/g // Split string by every two characters
)!
.map((byte) => parseInt(byte, 16))
);

if (!length) {
return buffer;
}

// If a length is specified, ensure we sufficiently pad
let paddedBuffer = new Uint8Array(length);
paddedBuffer.set(buffer, length - buffer.length);
return paddedBuffer;
}

function hexStringToBase64urlString(input: string, length?: number): string {
// Add an extra 0 to the start of the string to get a valid hex string (even length)
// (e.g. 0x0123 instead of 0x123)
const hexString = input.padStart(Math.ceil(input.length / 2) * 2, "0");
const buffer = hexStringToUint8Array(hexString, length);

return stringToBase64urlString(
buffer.reduce((result, x) => result + String.fromCharCode(x), "")
);
return jwk;
}
41 changes: 41 additions & 0 deletions packages/encoding/src/__tests__/index-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
base64StringToBase64UrlEncodedString,
base64urlToBuffer,
bufferToBase64url,
hexStringToBase64url,
} from "..";

// Test for stringToBase64urlString
Expand Down Expand Up @@ -98,4 +99,44 @@ test("uint8ArrayFromHexString", async function () {
133, 190, 199, 136, 134, 232, 226, 1, 175, 204, 177, 102, 252, 84, 193,
]);
expect(uint8ArrayFromHexString(hexString)).toEqual(expectedUint8Array); // Hex string => Uint8Array

expect(uint8ArrayFromHexString("627566666572").toString()).toEqual(
"98,117,102,102,101,114"
);

// Error case: empty string
expect(() => {
uint8ArrayFromHexString("");
}).toThrow("cannot create uint8array from invalid hex string");
// Error case: odd number of characters
expect(() => {
uint8ArrayFromHexString("123");
}).toThrow("cannot create uint8array from invalid hex string");
// Error case: bad characters outside of hex range
expect(() => {
uint8ArrayFromHexString("oops");
}).toThrow("cannot create uint8array from invalid hex string");
// Happy path: if length parameter is included, pad the resulting buffer
expect(uint8ArrayFromHexString("01", 2).toString()).toEqual("0,1");
// Happy path: if length parameter is omitted, do not pad the resulting buffer
expect(uint8ArrayFromHexString("01").toString()).toEqual("1");
// Error case: hex value cannot fit in desired length
expect(() => {
uint8ArrayFromHexString("0100", 1).toString(); // the number 256 cannot fit into 1 byte
}).toThrow("hex value cannot fit in a buffer of 1 byte(s)");
});

// Test for hexStringToBase64url
test("hexStringToBase64url", async function () {
expect(hexStringToBase64url("01")).toEqual("AQ");
expect(hexStringToBase64url("01", 2)).toEqual("AAE");

// extrapolate to larger numbers
expect(hexStringToBase64url("ff")).toEqual("_w"); // max 1 byte
expect(hexStringToBase64url("ff", 2)).toEqual("AP8"); // max 1 byte expressed in 2 bytes

// error case
expect(() => {
hexStringToBase64url("0100", 1);
}).toThrow("hex value cannot fit in a buffer of 1 byte(s)");
});
35 changes: 33 additions & 2 deletions packages/encoding/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/**
* Code modified from https://github.com/github/webauthn-json/blob/e932b3585fa70b0bd5b5a4012ba7dbad7b0a0d0f/src/webauthn-json/base64url.ts#L23
*/
export const DEFAULT_JWK_MEMBER_BYTE_LENGTH = 32;

export function stringToBase64urlString(input: string): string {
// string to base64 -- we do not rely on the browser's btoa since it's not present in React Native environments
const base64String = btoa(input);
Expand Down Expand Up @@ -45,6 +47,17 @@ export function bufferToBase64url(buffer: ArrayBuffer): string {
return base64urlString;
}

export function hexStringToBase64url(input: string, length?: number): string {
// Add an extra 0 to the start of the string to get a valid hex string (even length)
// (e.g. 0x0123 instead of 0x123)
const hexString = input.padStart(Math.ceil(input.length / 2) * 2, "0");
const buffer = uint8ArrayFromHexString(hexString, length);

return stringToBase64urlString(
buffer.reduce((result, x) => result + String.fromCharCode(x), "")
);
}

export function base64StringToBase64UrlEncodedString(input: string): string {
return input.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
Expand All @@ -56,16 +69,34 @@ export function uint8ArrayToHexString(input: Uint8Array): string {
);
}

export const uint8ArrayFromHexString = (hexString: string): Uint8Array => {
export const uint8ArrayFromHexString = (
hexString: string,
length?: number
): Uint8Array => {
const hexRegex = /^[0-9A-Fa-f]+$/;
if (!hexString || hexString.length % 2 != 0 || !hexRegex.test(hexString)) {
throw new Error(
`cannot create uint8array from invalid hex string: "${hexString}"`
);
}
return new Uint8Array(

const buffer = new Uint8Array(
hexString!.match(/../g)!.map((h: string) => parseInt(h, 16))
);

if (!length) {
return buffer;
}
if (hexString.length / 2 > length) {
throw new Error(
"hex value cannot fit in a buffer of " + length + " byte(s)"
);
}

// If a length is specified, ensure we sufficiently pad
let paddedBuffer = new Uint8Array(length);
paddedBuffer.set(buffer, length - buffer.length);
return paddedBuffer;
};

// Pure JS implementation of btoa. This is adapted from the following:
Expand Down

0 comments on commit c6d7a1e

Please sign in to comment.