diff --git a/packages/api-key-stamper/src/tink/elliptic_curves.ts b/packages/api-key-stamper/src/tink/elliptic_curves.ts index eb6bd9abb..90d60a204 100644 --- a/packages/api-key-stamper/src/tink/elliptic_curves.ts +++ b/packages/api-key-stamper/src/tink/elliptic_curves.ts @@ -7,6 +7,10 @@ */ import * as Bytes from "./bytes"; +import { + DEFAULT_JWK_MEMBER_BYTE_LENGTH, + uint8ArrayFromHexString, +} from "@turnkey/encoding"; /** * P-256 only @@ -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. */ @@ -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; diff --git a/packages/api-key-stamper/src/utils.ts b/packages/api-key-stamper/src/utils.ts index 8074983ed..07febeb8b 100644 --- a/packages/api-key-stamper/src/utils.ts +++ b/packages/api-key-stamper/src/utils.ts @@ -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; } diff --git a/packages/encoding/src/__tests__/index-test.ts b/packages/encoding/src/__tests__/index-test.ts index 6c5f10de9..841ad4012 100644 --- a/packages/encoding/src/__tests__/index-test.ts +++ b/packages/encoding/src/__tests__/index-test.ts @@ -6,6 +6,7 @@ import { base64StringToBase64UrlEncodedString, base64urlToBuffer, bufferToBase64url, + hexStringToBase64url, } from ".."; // Test for stringToBase64urlString @@ -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)"); }); diff --git a/packages/encoding/src/index.ts b/packages/encoding/src/index.ts index 7b63fb2ce..44712b310 100644 --- a/packages/encoding/src/index.ts +++ b/packages/encoding/src/index.ts @@ -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); @@ -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, ""); } @@ -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: