diff --git a/src/dpop.js b/src/dpop.js new file mode 100644 index 00000000..2bd38c57 --- /dev/null +++ b/src/dpop.js @@ -0,0 +1,31 @@ +/* @flow */ +/* eslint-disable promise/no-native, no-restricted-globals */ + +export const stringToBytes = (string: string): Uint8Array => { + return new Uint8Array([...string].map((c) => c.charCodeAt(0))); +}; + +export const bytesToString = (bytes: Uint8Array): string => { + return String.fromCharCode(...bytes); +}; + +export const base64encodeUrlSafe = (string: string): string => { + // https://datatracker.ietf.org/doc/html/rfc7515#appendix-C + return btoa(string) + .replace(/[=]+/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +}; + +export const base64decodeUrlSafe = (string: string): string => { + return atob(string.replace(/-/g, "+").replace(/_/g, "/")); +}; + +export const sha256 = async (string: string): Promise => { + const bytes = stringToBytes(string); + const digest = await window.crypto.subtle.digest("sha-256", bytes); + const binaryString = bytesToString(new Uint8Array(digest)); + return base64encodeUrlSafe(binaryString); +}; + +/* eslint-enable promise/no-native, no-restricted-globals */ diff --git a/src/dpop.test.js b/src/dpop.test.js new file mode 100644 index 00000000..e5833ae0 --- /dev/null +++ b/src/dpop.test.js @@ -0,0 +1,47 @@ +/* @flow */ + +import { describe, expect, it } from "vitest"; + +import { + base64decodeUrlSafe, + base64encodeUrlSafe, + bytesToString, + sha256, + stringToBytes, +} from "./dpop"; + +describe("DPoP", () => { + describe("base64 encoding and decoding", () => { + const decoded = "i·?i·>i·"; + const encoded = "abc_abc-abc"; + it("encoding replaces '/', '+', and '='", () => { + expect(btoa(decoded)).toEqual("abc/abc+abc="); + expect(base64encodeUrlSafe(decoded)).toEqual(encoded); + }); + it("decoding adds back the url unsafe characters", () => { + expect(base64decodeUrlSafe(encoded)).toEqual(decoded); + }); + }); + describe("byte array <-> string conversion", () => { + it("converts strings to bytes and back again", () => { + const string = "abcdefg123456890"; + expect(bytesToString(stringToBytes(string))).toEqual(string); + }); + it("converts bytes to binary strings and back again", () => { + // >= 128 should not be encoded as utf-8 + const bytes = new Uint8Array([128]); + expect(...stringToBytes(bytesToString(bytes))).toEqual(...bytes); + }); + }); + describe("sha256", () => { + it("base64 encodes the hash", async () => { + // testing a known string and its base64 encoded hash value from: + // https://datatracker.ietf.org/doc/html/rfc9449#name-dpop-protected-resource-req + const digest = await sha256( + "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU" + ); + expect(digest).toEqual("fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"); + expect.assertions(1); + }); + }); +}); diff --git a/test/globals.js b/test/globals.js index ff758af6..318515f9 100644 --- a/test/globals.js +++ b/test/globals.js @@ -1,4 +1,5 @@ /* eslint flowtype/require-valid-file-annotation: off, flowtype/require-return-type: off */ +import crypto from "crypto"; export const sdkClientTestGlobals = { __PORT__: 8000, @@ -20,4 +21,5 @@ export const sdkClientTestGlobals = { __EXPERIENCE__: "1122", __TREATMENT__: "1234", }, + crypto: crypto.webcrypto, };