Skip to content

Commit

Permalink
Merge pull request #34 from tkhq/olivia/settings
Browse files Browse the repository at this point in the history
Apply settings to import
  • Loading branch information
Olivia Thet authored Apr 17, 2024
2 parents 139a47d + 02a205a commit c227800
Show file tree
Hide file tree
Showing 3 changed files with 193 additions and 13 deletions.
144 changes: 139 additions & 5 deletions import/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
overflow-wrap: break-word;
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
}
textarea:focus {
#plaintext:focus {
outline: none;
border: none;
}
Expand All @@ -42,7 +42,8 @@
<script>
window.TKHQ = function() {
/** constants for LocalStorage */
const TURNKEY_TARGET_EMBEDDED_KEY = "TURNKEY_TARGET_EMBEDDED_KEY"
const TURNKEY_TARGET_EMBEDDED_KEY = "TURNKEY_TARGET_EMBEDDED_KEY";
const TURNKEY_SETTINGS = "TURNKEY_SETTINGS";

/*
* Load a key to encrypt to as a CryptoKey and return it as a JSON Web Key.
Expand All @@ -69,7 +70,7 @@
/**
* Gets the current target embedded private key JWK. Returns `null` if not found.
*/
function getTargetEmbeddedKey() {
function getTargetEmbeddedKey() {
const jwtKey = window.localStorage.getItem(TURNKEY_TARGET_EMBEDDED_KEY);
return jwtKey ? JSON.parse(jwtKey) : null;
}
Expand All @@ -89,6 +90,22 @@
window.localStorage.removeItem(TURNKEY_TARGET_EMBEDDED_KEY);
}

/**
* Gets the current settings.
*/
function getSettings() {
const settings = window.localStorage.getItem(TURNKEY_SETTINGS);
return settings ? JSON.parse(settings) : null;
}

/**
* Sets the settings object.
* @param {Object} settings
*/
function setSettings(settings) {
window.localStorage.setItem(TURNKEY_SETTINGS, JSON.stringify(settings));
}

/**
* Takes a hex string (e.g. "e4567ab") and returns an array buffer (Uint8Array)
* @param {string} hexString
Expand Down Expand Up @@ -292,6 +309,91 @@
return await crypto.subtle.verify({ name: "ECDSA", namedCurve: "P-256", hash: {name: "SHA-256" }}, quorumKey, publicSignatureBuf, publicKeyBuf);
}

/**
* Function to validate and sanitize the styles object using the accepted map of style keys and values (as regular expressions).
* Any invalid style throws an error. Returns an object of valid styles.
* @param {Object} styles
* @return {Object}
*/
function validateStyles(styles, element) {
const validStyles = {};

const cssValidationRegex = {
"padding": "^(\\d+(px|em|%|rem) ?){1,4}$",
"margin": "^(\\d+(px|em|%|rem) ?){1,4}$",
"borderWidth": "^(\\d+(px|em|rem) ?){1,4}$",
"borderStyle": "^(none|solid|dashed|dotted|double|groove|ridge|inset|outset)$",
"borderColor": "^(transparent|inherit|initial|#[0-9a-f]{3,8}|rgba?\\(\\d{1,3}, \\d{1,3}, \\d{1,3}(, \\d?(\\.\\d{1,2})?)?\\)|hsla?\\(\\d{1,3}, \\d{1,3}%, \\d{1,3}%(, \\d?(\\.\\d{1,2})?)?\\))$",
"borderRadius": "^(\\d+(px|em|%|rem) ?){1,4}$",
"fontSize": "^(\\d+(px|em|rem|%|vh|vw|in|cm|mm|pt|pc|ex|ch|vmin|vmax))$",
"fontWeight": "^(normal|bold|bolder|lighter|\\d{3})$",
"fontFamily": "^[^\";<>]*$", // checks for the absence of some characters that could lead to CSS/HTML injection
"color": "^(transparent|inherit|initial|#[0-9a-f]{3,8}|rgba?\\(\\d{1,3}, \\d{1,3}, \\d{1,3}(, \\d?(\\.\\d{1,2})?)?\\)|hsla?\\(\\d{1,3}, \\d{1,3}%, \\d{1,3}%(, \\d?(\\.\\d{1,2})?)?\\))$",
"backgroundColor": "^(transparent|inherit|initial|#[0-9a-f]{3,8}|rgba?\\(\\d{1,3}, \\d{1,3}, \\d{1,3}(, \\d?(\\.\\d{1,2})?)?\\)|hsla?\\(\\d{1,3}, \\d{1,3}%, \\d{1,3}%(, \\d?(\\.\\d{1,2})?)?\\))$",
"width": "^(\\d+(px|em|rem|%|vh|vw|in|cm|mm|pt|pc|ex|ch|vmin|vmax)|auto)$",
"height": "^(\\d+(px|em|rem|%|vh|vw|in|cm|mm|pt|pc|ex|ch|vmin|vmax)|auto)$",
"maxWidth": "^(\\d+(px|em|rem|%|vh|vw|in|cm|mm|pt|pc|ex|ch|vmin|vmax)|none)$",
"maxHeight": "^(\\d+(px|em|rem|%|vh|vw|in|cm|mm|pt|pc|ex|ch|vmin|vmax)|none)$",
"lineHeight": "^(\\d+(\\.\\d+)?(px|em|rem|%|vh|vw|in|cm|mm|pt|pc|ex|ch|vmin|vmax)|normal)$",
"boxShadow": "^(none|(\\d+(px|em|rem) ?){2,4} (#[0-9a-f]{3,8}|rgba?\\(\\d{1,3}, \\d{1,3}, \\d{1,3}(, \\d?(\\.\\d{1,2})?)?\\)) ?(inset)?)$",
"textAlign": "^(left|right|center|justify|initial|inherit)$",
"overflowWrap": "^(normal|break-word|anywhere)$",
"wordWrap": "^(normal|break-word)$",
"resize": "^(none|both|horizontal|vertical|block|inline)$",
};

Object.entries(styles).forEach(([property, value]) => {
const styleProperty = property.trim();
if (styleProperty.length === 0) {
throw new Error("css style property cannot be empty");
}
const styleRegexStr = cssValidationRegex[styleProperty];
if (!styleRegexStr) {
throw new Error(`invalid or unsupported css style property: "${styleProperty}"`);
}
const styleRegex = new RegExp(styleRegexStr);
const styleValue = value.trim();
if (styleValue.length == 0) {
throw new Error(`css style for "${styleProperty}" is empty`);
}
const isValidStyle = styleRegex.test(styleValue);
if (!isValidStyle) {
throw new Error(`invalid css style value for property "${styleProperty}"`);
}
validStyles[styleProperty] = styleValue;
})

return validStyles;
}

/**
* Function to apply settings on this page. For now, the only settings that can be applied
* are for "styles". Upon successful application, return the valid, sanitized settings JSON string.
* @param {string} settings
* @return {string}
*/
function applySettings(settings) {
const validSettings = {};
const settingsObj = JSON.parse(settings);
if (settingsObj.styles) {
// Valid styles will be applied the "plaintext" textarea HTML element.
const plaintextTextarea = document.getElementById("plaintext");
if (!plaintextTextarea) {
throw new Error("no plaintext textarea HTML element found to apply settings to.");
}

// Validate, sanitize, and apply the styles to the "plaintext" textarea.
const validStyles = TKHQ.validateStyles(settingsObj.styles);
Object.entries(validStyles).forEach(([key, value]) => {
plaintextTextarea.style[key] = value;
});

validSettings["styles"] = validStyles;
}

return JSON.stringify(validSettings);
}

/**
* Function to send a message. If this page is embedded as an iframe we'll use window.top.postMessage. Otherwise we'll display it in the DOM.
* @param type message type. Can be "PUBLIC_KEY_CREATED" or "BUNDLE_INJECTED"
Expand All @@ -311,6 +413,8 @@
getTargetEmbeddedKey,
setTargetEmbeddedKey,
resetTargetEmbeddedKey,
getSettings,
setSettings,
sendMessageUp,
uint8arrayFromHexString,
uint8arrayToHexString,
Expand All @@ -319,7 +423,9 @@
normalizePadding,
fromDerSignature,
additionalAssociatedData,
verifyEnclaveSignature
verifyEnclaveSignature,
validateStyles,
applySettings,
}
}();
</script>
Expand All @@ -334,6 +440,11 @@
import * as hpke from "https://esm.sh/@hpke/core";

document.addEventListener("DOMContentLoaded", async () => {
// If styles are saved in local storage, sanitize and apply them.
const styleSettings = TKHQ.getSettings();
if (styleSettings) {
TKHQ.applySettings(styleSettings);
}
// This is a workaround for how @turnkey/iframe-stamper is initialized. Currently,
// init() waits for a public key to be initialized that can be used to send to the server
// which will encrypt messages to this public key.
Expand All @@ -350,7 +461,6 @@
TKHQ.sendMessageUp("ERROR", e.toString());
}
}
// TODO: deprecate EXTRACT_WALLET_ENCRYPTED_BUNDLE in favor of EXTRACT_ENCRYPTED_BUNDLE
if (event.data && event.data["type"] == "EXTRACT_WALLET_ENCRYPTED_BUNDLE") {
try {
await onExtractWalletEncryptedBundle()
Expand All @@ -365,6 +475,13 @@
TKHQ.sendMessageUp("ERROR", e.toString());
}
}
if (event.data && event.data["type"] == "APPLY_SETTINGS") {
try {
await onApplySettings(event.data["value"])
} catch (e) {
TKHQ.sendMessageUp("ERROR", e.toString());
}
}
}, false);
}, false);

Expand Down Expand Up @@ -529,6 +646,23 @@
TKHQ.sendMessageUp("ENCRYPTED_BUNDLE_EXTRACTED", encryptedBundle)
}

/**
* Function triggered when APPLY_SETTINGS event is received.
* For now, the only settings that can be applied are for "styles".
* Persist them in local storage so they can be applied on every
* page load.
*/
async function onApplySettings(settings) {
// Apply settings
const validSettings = TKHQ.applySettings(settings);

// Persist in local storage
TKHQ.setSettings(validSettings);

// Send up SETTINGS_APPLIED message
TKHQ.sendMessageUp("SETTINGS_APPLIED", true);
}

async function HpkeEncrypt({ plaintextBuf, receiverPubJwk }) {
const kemContext = new hpke.DhkemP256HkdfSha256();
const receiverPub = await kemContext.importKey("jwk", {...receiverPubJwk}, true);
Expand Down
48 changes: 48 additions & 0 deletions import/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,52 @@ describe("TKHQ", () => {
TKHQ.verifyEnclaveSignature(null, "30440220773382ac39085f58a584fd5ad8c8b91b50993ad480af2c5eaefe0b09447b6dca02205201c8e20a92bce524caac08a956b0c2e7447de9c68f91ab1e09fd58988041b5", "")
).rejects.toThrow('cannot create uint8array from invalid hex string: ""');
})

it("validates styles", async () => {
let simpleValid = {"padding": "10px"};
expect(TKHQ.validateStyles(simpleValid)).toEqual(simpleValid);

simpleValid = {"padding": "10px", "margin": "10px","fontSize": "16px"};
expect(TKHQ.validateStyles(simpleValid)).toEqual(simpleValid);

let simpleValidPadding = {"padding ": "10px", "margin": "10px", "fontSize": "16px"};
expect(TKHQ.validateStyles(simpleValidPadding)).toEqual(simpleValid);

let simpleInvalidCase = {"padding": "10px", "margin": "10px", "font-size": "16px"};
expect(() => TKHQ.validateStyles(simpleInvalidCase)).toThrow(`invalid or unsupported css style property: "font-size"`);

let fontFamilyInvalid = {"fontFamily": "<script>malicious</script>"};
expect(() => TKHQ.validateStyles(fontFamilyInvalid)).toThrow(`invalid css style value for property "fontFamily"`);

fontFamilyInvalid = {"fontFamily": "\"Courier\""};
expect(() => TKHQ.validateStyles(fontFamilyInvalid)).toThrow(`invalid css style value for property "fontFamily"`);

fontFamilyInvalid = {"fontFamily": "San Serif;"};
expect(() => TKHQ.validateStyles(fontFamilyInvalid)).toThrow(`invalid css style value for property "fontFamily"`);

let allStylesValid = {
"padding": "10px",
"margin": "10px",
"borderWidth": "1px",
"borderStyle": "solid",
"borderColor": "transparent",
"borderRadius": "5px",
"fontSize": "16px",
"fontWeight": "bold",
"fontFamily": "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
"color": "#000000",
"backgroundColor": "rgb(128, 0, 128)",
"width": "100%",
"height": "auto",
"maxWidth": "100%",
"maxHeight": "100%",
"lineHeight": "1.25rem",
"boxShadow": "0px 0px 10px #aaa",
"textAlign": "center",
"overflowWrap": "break-word",
"wordWrap": "break-word",
"resize": "none",
};
expect(TKHQ.validateStyles(allStylesValid)).toEqual(allStylesValid);
})
})
14 changes: 6 additions & 8 deletions import/standalone.html
Original file line number Diff line number Diff line change
Expand Up @@ -573,20 +573,19 @@ <h2>Message log</h2>
* Function triggered when EXTRACT_WALLET_ENCRYPTED_BUNDLE event is received.
* Prerequisite: This function uses the target public key in local storage that is imported
* from the INJECT_IMPORT_BUNDLE event.
* Uses the target public key in local storage to encrypt the text entered in the
* `plaintext` textarea element. Upon successful encryption, sends
* Uses the target public key in local storage to encrypt the plaintextValue. Upon successful encryption, sends
* an `encrypted_bundle` containing the ciphertext and encapped public key.
* Example bundle: {"encappedPublic":"0497f33f3306f67f4402d4824e15b63b04786b6558d417aac2fef69051e46fa7bfbe776b142e4ded4f02097617a7588e93c53b71f900a4a8831a31be6f95e5f60f","ciphertext":"c17c3085505f3c094f0fa61791395b83ab1d8c90bdf9f12a64fc6e2e9cba266beb528f65c88bd933e36e6203752a9b63e6a92290a0ab6bf0ed591cf7bfa08006001e2cc63870165dc99ec61554ffdc14dea7d567e62cceed29314ae6c71a013843f5c06146dee5bf9c1d"}
*/
async function onExtractWalletEncryptedBundle(bundle) {
async function onExtractWalletEncryptedBundle(plaintextValue) {
// Get target embedded key from previous step (onInjectImportBundle)
const targetPublicKeyJwk = TKHQ.getTargetEmbeddedKey();
if (targetPublicKeyJwk == null) {
throw new Error("no target key found");
}

// Get plaintext wallet mnemonic
const plaintext = bundle.trim();
const plaintext = plaintextValue.trim();
if (!plaintext) {
throw new Error("no wallet mnemonic entered");
}
Expand All @@ -610,20 +609,19 @@ <h2>Message log</h2>
* Function triggered when EXTRACT_KEY_ENCRYPTED_BUNDLE event is received.
* Prerequisite: This function uses the target public key in local storage that is imported
* from the INJECT_IMPORT_BUNDLE event.
* Uses the target public key in local storage to encrypt the text entered in the
* `plaintext` textarea element. Upon successful encryption, sends
* Uses the target public key in local storage to encrypt the plaintextValue. Upon successful encryption, sends
* an `encrypted_bundle` containing the ciphertext and encapped public key.
* Example bundle: {"encappedPublic":"0497f33f3306f67f4402d4824e15b63b04786b6558d417aac2fef69051e46fa7bfbe776b142e4ded4f02097617a7588e93c53b71f900a4a8831a31be6f95e5f60f","ciphertext":"c17c3085505f3c094f0fa61791395b83ab1d8c90bdf9f12a64fc6e2e9cba266beb528f65c88bd933e36e6203752a9b63e6a92290a0ab6bf0ed591cf7bfa08006001e2cc63870165dc99ec61554ffdc14dea7d567e62cceed29314ae6c71a013843f5c06146dee5bf9c1d"}
*/
async function onExtractKeyEncryptedBundle(bundle, keyFormat) {
async function onExtractKeyEncryptedBundle(plaintextValue, keyFormat) {
// Get target embedded key from previous step (onInjectImportBundle)
const targetPublicKeyJwk = TKHQ.getTargetEmbeddedKey();
if (targetPublicKeyJwk == null) {
throw new Error("no target key found");
}

// Get plaintext private key
const plaintext = bundle.trim();
const plaintext = plaintextValue.trim();
if (!plaintext) {
throw new Error("no private key entered");
}
Expand Down

0 comments on commit c227800

Please sign in to comment.