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

migrate auth to indexeddb #53

Closed
wants to merge 4 commits into from
Closed
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
158 changes: 123 additions & 35 deletions auth/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ <h2>Stamp</h2>
<em
>Once you've injected the credential bundle, the credential is ready to
sign. A new <code>RECOVER</code> activity for example. This iframe
doesn't know anything about Turnkey activity however, it's a simple
doesn't know anything about Turnkey activities however, it's a simple
stamper!</em
>
</p>
Expand All @@ -149,23 +149,42 @@ <h2>Message log</h2>
<div id="message-log"></div>

<!--
Script defining important helpers.
These helpers are unit-testable, so most of the logic should be written here.
-->
Script defining important helpers.
These helpers are unit-testable, so most of the logic should be written here.
-->
<script>
window.TKHQ = (function () {
/** constant for LocalStorage */
/** constants for IndexedDB */
var TURNKEY_EMBEDDED_KEY = "TURNKEY_EMBEDDED_KEY";
var TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS = 1000 * 60 * 60 * 48; // 48 hours in milliseconds
var DB_NAME = "TurnkeyStorage";
var DB_VERSION = 1;
var STORE_NAME = "keyStore";

var openDB = function () {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(DB_NAME, DB_VERSION);

request.onerror = (event) =>
reject("IndexedDB error: " + event.target.error);

request.onsuccess = (event) => resolve(event.target.result);

request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore(STORE_NAME, { keyPath: "key" });
};
});
};

/**
* Creates a new public/private key pair and persists it in localStorage
* Creates a new public/private key pair and persists it in IndexedDB
*/
var initEmbeddedKey = async function () {
var retrievedKey = await getEmbeddedKey();
if (retrievedKey === null) {
var targetKey = await generateTargetKey();
setEmbeddedKey(targetKey);
await setEmbeddedKey(targetKey);
} else {
// Nothing to do, key is correctly initialized!
}
Expand All @@ -190,15 +209,18 @@ <h2>Message log</h2>
/**
* Gets the current embedded private key JWK. Returns `null` if not found.
*/
var getEmbeddedKey = function () {
var jwtKey = getItemWithExpiry(TURNKEY_EMBEDDED_KEY);
var getEmbeddedKey = async function () {
var jwtKey = await getItemWithExpiry(TURNKEY_EMBEDDED_KEY);
if (!jwtKey) {
return null;
} else {
return JSON.parse(jwtKey);
}
};

/**
* Sets a private key JWK in IndexedDB.
*/
var setEmbeddedKey = function (targetKey) {
return setItemWithExpiry(
TURNKEY_EMBEDDED_KEY,
Expand All @@ -207,51 +229,89 @@ <h2>Message log</h2>
);
};

/**
* Resets the embedded key.
*/
var onResetEmbeddedKey = function () {
window.localStorage.removeItem(TURNKEY_EMBEDDED_KEY);
return removeItem(TURNKEY_EMBEDDED_KEY);
};

/**
* Set an item in localStorage with an expiration time
* Set an item in IndexedDB with an expiration time
* @param {string} key
* @param {string} value
* @param {number} ttl expiration time in milliseconds
*/
var setItemWithExpiry = function (key, value, ttl) {
async function setItemWithExpiry(key, value, ttl) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);

const now = new Date();
const item = {
key: key,
value: value,
expiry: now.getTime() + ttl,
};
window.localStorage.setItem(key, JSON.stringify(item));
};

return new Promise((resolve, reject) => {
const request = store.put(item);
request.onerror = (event) =>
reject("Error in setItemWithExpiry: " + event.target.error);
request.onsuccess = () => resolve();
});
}

/**
* Get an item from localStorage. If it has expired, remove
* the item from localStorage and return null.
* Get an item from IndexedDB. If it has expired, remove
* the item from IndexedDB and return null.
* @param {string} key
*/
const getItemWithExpiry = (key) => {
const itemStr = window.localStorage.getItem(key);
async function getItemWithExpiry(key) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);

if (!itemStr) {
return null;
}
return new Promise((resolve, reject) => {
const request = store.get(key);

const item = JSON.parse(itemStr);
request.onerror = (event) =>
reject("Error in getItemWithExpiry: " + event.target.error);

if (!item.hasOwnProperty("expiry") || !item.hasOwnProperty("value")) {
window.localStorage.removeItem(key);
return null;
}
request.onsuccess = (event) => {
const item = event.target.result;
if (!item) {
resolve(null);
} else {
const now = new Date();
if (now.getTime() > item.expiry) {
// If the item has expired, delete it and return null
store.delete(key);
resolve(null);
} else {
resolve(item.value);
}
}
};
});
}

const now = new Date();
if (now.getTime() > item.expiry) {
window.localStorage.removeItem(key);
return null;
}
return item.value;
};
/**
* Removes an item from IndexedDB.
* @param {string} key
*/
async function removeItem(key) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);

return new Promise((resolve, reject) => {
const request = store.delete(key);
request.onerror = (event) =>
reject("Error in removeItem: " + event.target.error);
request.onsuccess = () => resolve();
});
}

/**
* Takes a hex string (e.g. "e4567abc") and returns an array buffer (Uint8Array)
Expand Down Expand Up @@ -973,6 +1033,7 @@ <h2>Message log</h2>
*********************************************************************************************/

return {
openDB,
initEmbeddedKey,
generateTargetKey,
setItemWithExpiry,
Expand Down Expand Up @@ -1006,10 +1067,37 @@ <h2>Message log</h2>
// In memory spot for the credential to live. We do NOT persist it to localStorage.
var CREDENTIAL_BYTES = null;

await TKHQ.initEmbeddedKey();

document.addEventListener(
"DOMContentLoaded",
async function () {
await TKHQ.initEmbeddedKey();
if (navigator.storage && navigator.storage.persist) {
navigator.storage.persist().then((persistent) => {
if (persistent) {
console.log(
"Storage will not be cleared except by explicit user action"
);
} else {
console.log(
"Storage may be cleared by the UA under storage pressure."
);
}
});
}

var promise = document.requestStorageAccess();
promise.then(
function (arg) {
// Storage access was granted.
console.log("got perms", arg);
},
function (arg) {
// Storage access was denied.
console.log("no perms", arg);
}
);

var embeddedKeyJwk = await TKHQ.getEmbeddedKey();
var targetPubBuf = await TKHQ.p256JWKPrivateToPublic(embeddedKeyJwk);
var targetPubHex = TKHQ.uint8arrayToHexString(targetPubBuf);
Expand Down Expand Up @@ -1048,7 +1136,7 @@ <h2>Message log</h2>
if (event.data && event.data["type"] == "RESET_EMBEDDED_KEY") {
TKHQ.logMessage(`⬇️ Received message ${event.data["type"]}`);
try {
TKHQ.onResetEmbeddedKey();
await TKHQ.onResetEmbeddedKey();
} catch (e) {
TKHQ.sendMessageUp("ERROR", e.toString());
}
Expand Down
53 changes: 33 additions & 20 deletions auth/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { JSDOM } from "jsdom";
import fs from "fs";
import path from "path";
import * as crypto from "crypto";
import { indexedDB } from "fake-indexeddb";

const html = fs.readFileSync(path.resolve(__dirname, "./index.html"), "utf8");

let dom;
let TKHQ;

describe("TKHQ", () => {
beforeEach(() => {
beforeEach(async () => {
dom = new JSDOM(html, {
// Necessary to run script tags
runScripts: "dangerously",
Expand All @@ -24,50 +25,62 @@ describe("TKHQ", () => {
value: crypto.webcrypto,
});

Object.defineProperty(dom.window, "indexedDB", {
value: indexedDB,
});

TKHQ = dom.window.TKHQ;
await TKHQ.openDB();
});

it("gets and sets items with expiry localStorage", async () => {
// Set a TTL of 1000ms
TKHQ.setItemWithExpiry("k", "v", 1000);
let item = JSON.parse(dom.window.localStorage.getItem("k"));
expect(item.value).toBe("v");
expect(item.expiry).toBeTruthy();
await TKHQ.setItemWithExpiry("k", "v", 1000);

// TODO
// probably need wrappers/handlers to manipulate indexeddb directly
// let item = JSON.parse(dom.window.localStorage.getItem("k"));
// expect(item.value).toBe("v");
// expect(item.expiry).toBeTruthy();

// Get item that has not expired yet
item = TKHQ.getItemWithExpiry("k");
let item = await TKHQ.getItemWithExpiry("k");
expect(item).toBe("v");

// Set a TTL of 500ms
TKHQ.setItemWithExpiry("a", "b", 500);
setTimeout(() => {
const expiredItem = TKHQ.getItemWithExpiry("a");
await TKHQ.setItemWithExpiry("a", "b", 500);
setTimeout(async () => {
const expiredItem = await TKHQ.getItemWithExpiry("a");
expect(expiredItem).toBeNull();
}, 600); // Wait for 600ms to ensure the item has expired

// Returns null if getItemWithExpiry is called for item without expiry
dom.window.localStorage.setItem("k", JSON.stringify({ value: "v" }));
item = TKHQ.getItemWithExpiry("k");
expect(item).toBeNull();
// TODO
// Returns null if getItemWithExpiry is called for an item that does not have an expiry
// dom.window.localStorage.setItem("k", JSON.stringify({ value: "v" }));
// item = TKHQ.getItemWithExpiry("k");
// expect(item).toBeNull();
});

it("gets and sets embedded key in localStorage", async () => {
expect(TKHQ.getEmbeddedKey()).toBe(null);
it("gets, sets, and resets embedded key in localStorage", async () => {
expect(await TKHQ.getEmbeddedKey()).toBe(null);

// Set a dummy "key"
TKHQ.setEmbeddedKey({ foo: "bar" });
expect(TKHQ.getEmbeddedKey()).toEqual({ foo: "bar" });
await TKHQ.setEmbeddedKey({ foo: "bar" });
expect(await TKHQ.getEmbeddedKey()).toEqual({ foo: "bar" });

await TKHQ.onResetEmbeddedKey();
expect(await TKHQ.getEmbeddedKey()).toBe(null);
});

it("inits embedded key and is idempotent", async () => {
expect(TKHQ.getEmbeddedKey()).toBe(null);
expect(await TKHQ.getEmbeddedKey()).toBe(null);
await TKHQ.initEmbeddedKey();
var generatedKey = TKHQ.getEmbeddedKey();
var generatedKey = await TKHQ.getEmbeddedKey();
expect(generatedKey).not.toBeNull();

// This should have no effect; generated key should stay the same
await TKHQ.initEmbeddedKey();
expect(TKHQ.getEmbeddedKey()).toEqual(generatedKey);
expect(await TKHQ.getEmbeddedKey()).toEqual(generatedKey);
});

it("generates P256 keys", async () => {
Expand Down
16 changes: 16 additions & 0 deletions auth/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
"@babel/preset-env": "^7.22.20",
"@testing-library/dom": "^9.3.3",
"@testing-library/jest-dom": "^6.1.3",
"jsdom": "^22.1.0",
"babel-jest": "^29.7.0",
"fake-indexeddb": "^6.0.0",
"jest": "^29.7.0",
"serve": "^14.2.1",
"prettier": "^2.8.4"
"jsdom": "^22.1.0",
"prettier": "^2.8.4",
"serve": "^14.2.1"
}
}
Loading