Compare commits

..

No commits in common. "01d817a206e995953a50f371be5f18b3a199eb5a" and "141788714be940187133fcd8c99ad0a35a1a13a6" have entirely different histories.

11 changed files with 121 additions and 554 deletions

View File

@ -41,17 +41,17 @@
<meta name="msapplication-starturl" content="/" /> <meta name="msapplication-starturl" content="/" />
</head> </head>
<body style="opacity: 0;"> <body style="opacity: 0; background-color: #11191f;">
<header class="container"> <header class="container">
<hgroup> <hgroup>
<h1> <h1>
SURE SURE
</h1> </h1>
<h3> <p>
<span style="color: #1095C1;">S</span>ecure <span style="color: #44a616;">S</span>ecure
<span style="color: #1095C1;">U</span>RL <span style="color: #44a616;">U</span>RL
<span style="color: #1095C1;">Re</span>quests <span style="color: #44a616;">Re</span>quests
</h3> </p>
</hgroup> </hgroup>
</header> </header>
@ -67,83 +67,38 @@
</article> </article>
</dialog> </dialog>
<div id="keyManagerContainer"></div>
<main class="container"> <main class="container">
<section> <section>
<div id="app"></div> <div id="app"></div>
<details> <details>
<summary>How it Works:</summary> <summary>How it Works:</summary>
<p><strong>The first thing to understand is how public key cryptography works.</strong></p>
<p>Essentially, you generate a pair of keys that can undo the operations of each other. If you encrypt a message
with your public key, it can only be decrypted by your private key.</p>
<div style="width: fit-content; margin: auto;">
<img src="/img/public_key_crypto_chart.png" alt="Chart of the public key cryptography process" />
</div>
<p style="margin-top: 20px">This is why you're safe to share your "Request URL" out in the open. Anyone who
accesses this URL will receive your public key, and encrypt the message with it along with their own private
key. When you receive the URL with their encrypted message, you take their public key and your private key to
undo the operations of their private key and your public key.
</p>
<p><strong>But of course it's not as simple as that</strong></p>
<p>Traditional public-key cryptography like RSA is limited in the messages it can encrypt by the size of the
public key. To overcome this limitation, we use ECDH for short public keys that can encrypt an arbitrary
amount of data by establishing a shared "symmetric" key for encryption/decryption, which is beyond the scope
of this explanation. But the following chart should give you a general idea of how the process works.</p>
<img src="/img/flowchart.png" alt="Flowchart of the SURE process" />
</details>
<details>
<summary>FAQ</summary>
<ul> <ul>
<li> <li>Each client generates an ECDH keypair, consisting of a public key and a private key.</li>
<strong>What is this?</strong> <li>Your private key is kept in localStorage, and never leaves your device.</li>
<p> <li>Your public key is embedded in the URLs you generate. This key can be safely shared anywhere without
SURE is a system for securely sending information via a URL request. It uses the Elliptic Curve compromising security.</li>
Diffie-Hellman algorithm to generate a shared secret between the sender and recipient, which is then used <li>When another client opens your generated URL, they will find your public ECDH key. They then generate a
to encrypt the message in a response URL. The device that generated the URL request can then decrypt the random IV for this specific message, and use it, along with their private ECDH key and your public ECDH key,
message in the URL sent back to them using their private key. to derive a shared secret (AES-GCM).</li>
</p> <li>This derived shared secret never leaves their device. It is used to encrypt their message to you.
</li> The encrypted message, along with their public key and the IV for this message, are embedded in the URL they
<li> generate.</li>
<strong>What if I want all of my devices to be able to decrypt messages from the same request?</strong> <li>Upon opening the response URL, your device uses your private ECDH key, along with the public key and IV
<p> from the URL, to recreate the shared secret. This secret is used to decrypt the message. If the message was
If you want to be able to decrypt messages from the same request on multiple devices, you'll need to properly encrypted using the expected keys, it will be successfully decrypted and displayed to you.</li>
export the keypair from the device that generated the request, and then import it into the other <li>If you clear your browser's local storage, you will not be able to decrypt any response URLs generated
devices. You can do this by clicking the "Manage Keys" link at the bottom of the page, and then clicking with your previous unique URL.</li>
the "Export Keys" button. This will give you a JSON file that you can import on other devices via the
"Import Keys" button.
</p>
</li>
<li>
<strong>What if I lose my keys?</strong>
<p>
If you lose your keys, either from clearing your localstorage or losing your device, you won't be able to
decrypt any messages sent to you. You'll need to send over the new request URL from your device to anyone
who wants to send you a message.
</p>
</li>
<li>
<strong>Why was this built?</strong>
<p>
This was built as a proof of concept for a secure messaging system that doesn't require a user to sign up
for an account or download an app. It can even work entirely offline if you build the app from source and
open the HTML file in your browser. You can also manage your keys so that all of your devices can decrypt
messages from the same request URL.
</li>
</ul> </ul>
</details> </details>
</section> </section>
</main> </main>
<footer class="container"> <footer class="container">
<div style="width: fit-content; margin: auto;"> <p>
<a href="https://git.silentsilas.com/silentsilas/sure" target="_blank" rel="noopener noreferrer">Source Code</a> <a href="https://git.silentsilas.com/silentsilas/sure" target="_blank" rel="noopener noreferrer">Source Code</a> |
| <a href="https://silentsilas.com" target="_blank" rel="noopener noreferrer">whoami</a>
<a href="https://silentsilas.com" target="_blank" rel="noopener noreferrer">whoami</a> | </p>
<a href="#" id="openKeyManager">Manage Keys</a>
</div>
</footer> </footer>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>

154
package-lock.json generated
View File

@ -1,15 +1,14 @@
{ {
"name": "sure", "name": "sure-ts",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sure", "name": "sure-ts",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@picocss/pico": "^1.5.11", "@picocss/pico": "^1.5.11"
"uhtml": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.2.2", "typescript": "^5.2.2",
@ -390,16 +389,6 @@
"resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-1.5.11.tgz", "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-1.5.11.tgz",
"integrity": "sha512-cDaFiSyNPtuSTwSQt/05xsw8+g2ek4/S58tgh9Nc7miJCCdUrY9PAyl4OPWRJtYgJDdEvkUA9GuGj0J4nDP4Cw==" "integrity": "sha512-cDaFiSyNPtuSTwSQt/05xsw8+g2ek4/S58tgh9Nc7miJCCdUrY9PAyl4OPWRJtYgJDdEvkUA9GuGj0J4nDP4Cw=="
}, },
"node_modules/@preact/signals-core": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.5.1.tgz",
"integrity": "sha512-dE6f+WCX5ZUDwXzUIWNMhhglmuLpqJhuy3X3xHrhZYI0Hm2LyQwOu0l9mdPiWrVNsE+Q7txOnJPgtIqHCYoBVA==",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.9.6", "version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz",
@ -575,92 +564,6 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true "dev": true
}, },
"node_modules/@webreflection/signal": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@webreflection/signal/-/signal-2.0.0.tgz",
"integrity": "sha512-qtReCZZOK2x6d4ObGg4VfiNQOpPxcVZ2VGx0Yevlw0fTJ5PJsK3cDN/SXEI+equTfkNcDgtEIh37eIVj0oaqJw==",
"optional": true
},
"node_modules/@webreflection/uparser": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@webreflection/uparser/-/uparser-0.3.3.tgz",
"integrity": "sha512-XxGfo8jr2eVuvP5lrmwjgMAM7QjtZ0ngFD+dd9Fd3GStcEb4QhLlTiqZYF5O3l5k4sU/V6ZiPrVCzCWXWFEmCw==",
"dependencies": {
"domconstants": "^1.1.6"
}
},
"node_modules/custom-function": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/custom-function/-/custom-function-1.0.6.tgz",
"integrity": "sha512-styyvwOki/EYr+VBe7/m9xAjq6uKx87SpDKIpFRdTQnofBDSZpBEFc9qJLmaJihjjTeEpAIJ+nz+9fUXj+BPNQ=="
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domconstants": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/domconstants/-/domconstants-1.1.6.tgz",
"integrity": "sha512-CuaDrThJ4VM+LyZ4ax8n52k0KbLJZtffyGkuj1WhpTRRcSfcy/9DfOBa68jenhX96oNUTunblSJEUNC4baFdmQ=="
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.19.12", "version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
@ -713,34 +616,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/gc-hook": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz",
"integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A=="
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="
},
"node_modules/htmlparser2": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
"integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.1.0",
"entities": "^4.5.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@ -847,29 +722,6 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/udomdiff": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/udomdiff/-/udomdiff-1.1.0.tgz",
"integrity": "sha512-aqjTs5x/wsShZBkVagdafJkP8S3UMGhkHKszsu1cszjjZ7iOp86+Qb3QOFYh01oWjPMy5ZTuxD6hw5uTKxd+VA=="
},
"node_modules/uhtml": {
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/uhtml/-/uhtml-4.4.7.tgz",
"integrity": "sha512-Y4iuj8bGyP+ryk2J+9T4xhOWymnizB7QyTdXF/mA+B3rFNCC5GQq06+wI64QCMvvcNjQMBRbI2kMFppcEJZGWA==",
"dependencies": {
"@webreflection/uparser": "^0.3.3",
"custom-function": "^1.0.6",
"domconstants": "^1.1.6",
"gc-hook": "^0.3.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^9.1.0",
"udomdiff": "^1.1.0"
},
"optionalDependencies": {
"@preact/signals-core": "^1.5.1",
"@webreflection/signal": "^2.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.0.12", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz",

View File

@ -14,7 +14,6 @@
"vite-plugin-make-offline": "^1.0.0" "vite-plugin-make-offline": "^1.0.0"
}, },
"dependencies": { "dependencies": {
"@picocss/pico": "^1.5.11", "@picocss/pico": "^1.5.11"
"uhtml": "^4.4.7"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@ -3,64 +3,37 @@ import "./style.css";
import { setupRequestPage } from "./routes/request.ts"; import { setupRequestPage } from "./routes/request.ts";
import { setupSendPage } from "./routes/send.ts"; import { setupSendPage } from "./routes/send.ts";
import { setupReceivePage } from "./routes/receive.ts"; import { setupReceivePage } from "./routes/receive.ts";
import { initKeyManager } from "./utils/keyManager.ts";
type PageState = { const start = () => {
page: string; const fragmentData = new URLSearchParams(window.location.hash.slice(1));
params: URLSearchParams;
};
let state: PageState = {
page: "request",
params: new URLSearchParams(window.location.hash.slice(1)),
};
const renderPage = () => {
const appElement = document.querySelector<HTMLElement>("#app"); const appElement = document.querySelector<HTMLElement>("#app");
if (!appElement) { if (!appElement) {
throw new Error("No app element found"); throw new Error("No app element found");
} }
switch (state.page) { // If the URL contains the 'p', 'iv', and 'm' parameters, then the receiver page is set up
case "request": if (
setupRequestPage(appElement); fragmentData.has("p") &&
break; fragmentData.has("iv") &&
case "send": fragmentData.has("m")
setupSendPage(appElement, state.params.get("p")!); ) {
break;
case "receive":
setupReceivePage(appElement, { setupReceivePage(appElement, {
p: decodeURIComponent(state.params.get("p")!), p: decodeURIComponent(fragmentData.get("p")!),
iv: decodeURIComponent(state.params.get("iv")!), iv: decodeURIComponent(fragmentData.get("iv")!),
m: decodeURIComponent(state.params.get("m")!), m: decodeURIComponent(fragmentData.get("m")!),
}); });
break; return;
} }
};
// Define a function to update the state and re-render // If the URL contains the 'p' parameter, then the sender page is set up
const setState = (newState: PageState) => { if (fragmentData.has("p")) {
state = { ...state, ...newState }; setupSendPage(appElement, fragmentData.get("p")!);
renderPage(); return;
};
const update = () => {
const params = new URLSearchParams(window.location.hash.slice(1));
if (params.has("p") && params.has("iv") && params.has("m")) {
setState({ page: "receive", params });
} else if (params.has("p")) {
setState({ page: "send", params });
} else {
setState({ page: "request", params });
} }
// Otherwise, the request page is set up
setupRequestPage(appElement);
}; };
// Listen for changes in the hash and update the state accordingly start();
window.addEventListener("hashchange", update);
// Initialize the app
update();
renderPage();
initKeyManager(document.querySelector("#keyManagerContainer"));

View File

@ -1,4 +1,3 @@
import { html, render } from "uhtml";
import { import {
deriveSharedSecret, deriveSharedSecret,
decrypt, decrypt,
@ -6,26 +5,12 @@ import {
} from "../utils/crypto"; } from "../utils/crypto";
const MESSAGE_OUTPUT_ID = "message"; const MESSAGE_OUTPUT_ID = "message";
const TEMPLATE = `
type PageState = {
decryptedData: string;
};
let state: PageState = {
decryptedData: "",
};
// Move the template to a `uhtml` function
const Template = (state: PageState) => html`
<details open> <details open>
<summary>How To Use:</summary> <summary>How To Use:</summary>
<ul> <ul>
<li> <li>If someone used your unique request URL to generate this response URL, you should see the decrypted message below.</li>
If someone used your unique request URL to generate this response URL, <li>Be sure to open this from the same browser you used to generate the original request URL.</li>
you should see the decrypted message below.
</li>
<li>
Be sure to open this from the same browser you used to generate the
original request URL.
</li>
</ul> </ul>
</details> </details>
@ -34,7 +19,6 @@ const Template = (state: PageState) => html`
id="${MESSAGE_OUTPUT_ID}" id="${MESSAGE_OUTPUT_ID}"
readonly readonly
aria-label="Decrypted Message" aria-label="Decrypted Message"
.value=${state.decryptedData}
></textarea> ></textarea>
</form> </form>
`; `;
@ -43,18 +27,12 @@ export async function setupReceivePage(
element: HTMLElement, element: HTMLElement,
params: { p: string; iv: string; m: string } params: { p: string; iv: string; m: string }
) { ) {
decryptData(params, element); element.innerHTML = TEMPLATE;
decryptData(params);
} }
const setState = (newState: PageState, element: HTMLElement) => { async function decryptData({ p, iv, m }: { p: string; iv: string; m: string }) {
state = { ...state, ...newState };
render(element, Template(state));
};
async function decryptData(
{ p, iv, m }: { p: string; iv: string; m: string },
element: HTMLElement
) {
// Parse the 'p' parameter to get publicB // Parse the 'p' parameter to get publicB
const publicBJwk = JSON.parse(atob(p)); const publicBJwk = JSON.parse(atob(p));
@ -79,6 +57,8 @@ async function decryptData(
const decryptedData = await decrypt(aesKey, iv, m); const decryptedData = await decrypt(aesKey, iv, m);
// Update the message output with the decrypted message // Update the message output with the decrypted message
setState({ decryptedData }, element); const messageOutput = document.getElementById(
render(element, Template(state)); MESSAGE_OUTPUT_ID
) as HTMLTextAreaElement;
messageOutput.value = decryptedData;
} }

View File

@ -1,18 +1,13 @@
import { html, render } from "uhtml";
import { retrieveOrGenerateKeyPair } from "../utils/crypto.ts"; import { retrieveOrGenerateKeyPair } from "../utils/crypto.ts";
const URL_INPUT_ID = "request-url"; const URL_INPUT_ID = "request-url";
const COPY_BUTTON_ID = "copy-button"; const TEMPLATE = `
export async function setupRequestPage(element: HTMLElement) {
const url = await generateUrl();
const template = html`
<details open> <details open>
<summary>How To Use:</summary> <summary>How To Use:</summary>
<ol> <ol>
<li>Send the link below to someone you want to get a secret from.</li> <li><strong>Request:</strong> Copy and share the URL below with the person you want to receive data from.</li>
<li>They add their secret and share the URL the app generates.</li> <li><strong>Send:</strong> The recipient will open the URL, enter their data, and hit 'Generate Response'. This generates a new URL with their encrypted message.</li>
<li>Only your browser can open the URL and decrypt the secret.</li> <li><strong>Receive:</strong> They send you the newly generated URL. Open it to view the decrypted message. Only your browser can decrypt it.</li>
</ol> </ol>
</details> </details>
@ -20,37 +15,31 @@ export async function setupRequestPage(element: HTMLElement) {
<input <input
type="text" type="text"
name="request-url" name="request-url"
.id=${URL_INPUT_ID} id="${URL_INPUT_ID}"
.value=${url.toString()}
placeholder="" placeholder=""
aria-label="Request URL" aria-label="Request URL"
required required
/> />
<button
aria-label="Copy to Clipboard"
.id=${COPY_BUTTON_ID}
@click=${copyToClipboard}
>
Copy to Clipboard
</button>
</form> </form>
`; `;
render(element, template); export async function setupRequestPage(element: HTMLElement) {
} element.innerHTML = TEMPLATE;
async function copyToClipboard(event: Event) { const url = await generateUrl();
event.preventDefault();
const input = document.getElementById(URL_INPUT_ID) as HTMLInputElement; const input = document.getElementById(URL_INPUT_ID) as HTMLInputElement;
await navigator.clipboard.writeText(input.value); input.value = url.toString();
alert("Copied to clipboard!");
} }
async function generateUrl(): Promise<URL> { async function generateUrl(): Promise<URL> {
const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair(); const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair();
// Generate URL with public key as the 'p' search parameter
const ecdhPublicJwk = await window.crypto.subtle.exportKey("jwk", ecdhPublic); const ecdhPublicJwk = await window.crypto.subtle.exportKey("jwk", ecdhPublic);
const url = new URL(window.location.toString()); const url = new URL(window.location.toString());
url.search = ""; url.search = "";
url.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`; url.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
// Return the generated URL
return url; return url;
} }

View File

@ -1,4 +1,3 @@
import { html, render } from "uhtml";
import { import {
deriveSharedSecret, deriveSharedSecret,
encrypt, encrypt,
@ -9,96 +8,50 @@ const ENCRYPTED_URL_INPUT_ID = "encrypted-url";
const MESSAGE_INPUT_ID = "message"; const MESSAGE_INPUT_ID = "message";
const ENCRYPT_BUTTON_ID = "encrypt"; const ENCRYPT_BUTTON_ID = "encrypt";
const REQUEST_PUBLIC_KEY = "requestPublicKey"; const REQUEST_PUBLIC_KEY = "requestPublicKey";
const COPY_BUTTON_ID = "copy-button"; const TEMPLATE = `
type PageState = {
message: string;
encryptedUrl: string;
isCopyButtonDisabled: boolean;
container: HTMLElement | null;
};
const state: PageState = {
message: "",
encryptedUrl: "",
isCopyButtonDisabled: true,
container: null,
};
const update = (element: HTMLElement) => {
// Re-render the component whenever the state changes
render(element, App(state));
};
const App = (state: PageState) => html`
<details open> <details open>
<summary>How To Use:</summary> <summary>How To Use:</summary>
<ol> <ol>
<li> <li>Enter the information you want to send back to the original requester into the text input below.</li>
Enter the information you want to send back to the original requester <li>Click on 'Generate Response'. This will create a new URL that contains your encrypted message.</li>
into the text input below. <li>Send the newly generated URL back to the original requester. Only their browser will be able to decrypt the message.</li>
</li>
<li>
Click on 'Generate Response'. This will create a new URL that contains
your encrypted message.
</li>
<li>
Send the newly generated URL back to the original requester. Only their
browser will be able to decrypt the message.
</li>
</ol> </ol>
</details> </details>
<form> <form>
<input <input
type="text" type="text"
name="message" name="message"
.id=${MESSAGE_INPUT_ID} id="${MESSAGE_INPUT_ID}"
placeholder="Enter your message here..." placeholder="Enter your message here..."
aria-label="Messag aria-label="Message to Encrypt"
to Encrypt"
required required
value=${state.message}
@input=${(e: Event) => {
state.message = (e.target as HTMLInputElement).value;
update(state.container!);
}}
/> />
<input <input
.id=${ENCRYPT_BUTTON_ID} id="${ENCRYPT_BUTTON_ID}"
type="submit" type="submit"
value="Generate Response" value="Generate Response"
aria-label="Generate Response" aria-label="Generate Response"
@click=${encryptData}
/> />
<input <input
type="text" type="text"
name="encrypted-url" name="encrypted-url"
.id=${ENCRYPTED_URL_INPUT_ID} id="${ENCRYPTED_URL_INPUT_ID}"
placeholder="URL with your encrypted response placeholder="URL with your encrypted response will appear here..."
will appear here..."
aria-label="Encrypted URL" aria-label="Encrypted URL"
value=${state.encryptedUrl}
/> />
<button
aria-label="Copy to Clipboard"
.id=${COPY_BUTTON_ID}
@click=${copyToClipboard}
disabled=${state.isCopyButtonDisabled}
>
Copy to Clipboard
</button>
</form> </form>
`; `;
export async function setupSendPage(element: HTMLElement, key: string) { export async function setupSendPage(element: HTMLElement, key: string) {
element.innerHTML = TEMPLATE;
localStorage.setItem(REQUEST_PUBLIC_KEY, key); localStorage.setItem(REQUEST_PUBLIC_KEY, key);
state.container = element;
update(element);
}
async function copyToClipboard(event: Event) { // Add an event listener to the "Encrypt" button
event.preventDefault(); const encryptButton = document.getElementById(
await navigator.clipboard.writeText(state.encryptedUrl); ENCRYPT_BUTTON_ID
alert("Copied to clipboard! Send this URL to the requester."); ) as HTMLButtonElement;
encryptButton.addEventListener("click", encryptData);
} }
async function encryptData(event: Event) { async function encryptData(event: Event) {
@ -122,11 +75,21 @@ async function encryptData(event: Event) {
const keyPairB = await retrieveOrGenerateKeyPair(); const keyPairB = await retrieveOrGenerateKeyPair();
// Retrieve the message input
const messageInput = document.getElementById(
MESSAGE_INPUT_ID
) as HTMLInputElement;
// Derive the AES key from your private key and the recipient's public key // Derive the AES key from your private key and the recipient's public key
const aesKey = await deriveSharedSecret(keyPairB.privateKey, publicA); const aesKey = await deriveSharedSecret(keyPairB.privateKey, publicA);
// Encrypt the message input value using the AES key // Encrypt the message input value using the AES key
const { encryptedData, iv } = await encrypt(aesKey, state.message); const { encryptedData, iv } = await encrypt(aesKey, messageInput.value);
// Update the encrypted URL input with the encrypted message
const encryptedUrlInput = document.getElementById(
ENCRYPTED_URL_INPUT_ID
) as HTMLInputElement;
const ecdhPublicJwk = await window.crypto.subtle.exportKey( const ecdhPublicJwk = await window.crypto.subtle.exportKey(
"jwk", "jwk",
@ -144,7 +107,5 @@ async function encryptData(event: Event) {
) )
)}`; )}`;
state.encryptedUrl = url.toString(); encryptedUrlInput.value = url.toString();
state.isCopyButtonDisabled = false;
update(state.container!);
} }

View File

@ -80,11 +80,6 @@ async function saveKeys(ecdhPublic: CryptoKey, ecdhPrivate: CryptoKey) {
// Store ECDH key pair in local storage // Store ECDH key pair in local storage
localStorage.setItem("ecdhPublic", JSON.stringify(ecdhPublicJwk)); localStorage.setItem("ecdhPublic", JSON.stringify(ecdhPublicJwk));
localStorage.setItem("ecdhPrivate", JSON.stringify(ecdhPrivateJwk)); localStorage.setItem("ecdhPrivate", JSON.stringify(ecdhPrivateJwk));
return {
public: JSON.stringify(ecdhPublicJwk),
private: JSON.stringify(ecdhPrivateJwk),
};
} }
export async function encrypt( export async function encrypt(
@ -146,57 +141,3 @@ function base64ToUint8Array(base64: string): Uint8Array {
} }
return bytes; return bytes;
} }
function downloadFile(content: string, fileName: string, contentType: string) {
const a = document.createElement("a");
const file = new Blob([content], { type: contentType });
a.href = URL.createObjectURL(file);
a.download = fileName;
a.click();
URL.revokeObjectURL(a.href);
}
export async function exportKeys(): Promise<void> {
const keys = await retrieveOrGenerateKeyPair();
const exportedKeys = await saveKeys(keys.publicKey, keys.privateKey);
const exportedKeysJson = JSON.stringify(exportedKeys);
downloadFile(exportedKeysJson, "keys.json", "application/json");
}
export async function importAndSaveKeys(keys: {
public: string;
private: string;
}): Promise<void> {
const ecdhPublicJwk = JSON.parse(keys.public);
const ecdhPrivateJwk = JSON.parse(keys.private);
// Import the keys
const ecdhPublic = await window.crypto.subtle.importKey(
"jwk",
ecdhPublicJwk,
{
name: "ECDH",
namedCurve: "P-256",
},
true,
[]
);
const ecdhPrivate = await window.crypto.subtle.importKey(
"jwk",
ecdhPrivateJwk,
{
name: "ECDH",
namedCurve: "P-256",
},
true,
["deriveKey", "deriveBits"]
);
// Save the imported keys
saveKeys(ecdhPublic, ecdhPrivate);
}

View File

@ -1,83 +0,0 @@
import { html } from "uhtml";
import {
exportKeys,
importAndSaveKeys,
retrieveOrGenerateKeyPair,
} from "./crypto";
import { reactive } from "uhtml/reactive";
import { effect, signal } from "uhtml/preactive";
const render = reactive(effect);
const ecdhPublicKey = signal(localStorage.getItem("ecdhPublic"));
const ecdhPrivateKey = signal(localStorage.getItem("ecdhPrivate"));
const MODAL_ID = "keyManagerModal";
const template = html` <dialog class="key-manager" .id=${MODAL_ID}>
<article>
<header>Key Manager</header>
<p>
If you'd like to manage your keys, you can do so here. You can export your keys to transfer to a different
device, or import keys exported from a different device.
</p>
<p>
Public Key:
<pre>${ecdhPublicKey}</pre>
</p>
<p>
Private Key:
<pre>${ecdhPrivateKey}</pre>
</p>
<p>
<button @click=${exportKeys}>Export Keys</button>
<button @click=${importKeys}>Import Keys</button>
<input type="file" accept=".json" style="display: none" id="keypairImportEl" @change=${handleKeypairImport} />
<button @click=${close}>Close</button>
</p>
</article>
</dialog>`;
function importKeys(): void {
const el = document.querySelector("#keypairImportEl");
if (!el) {
throw new Error("No file input found");
}
(el as HTMLElement).click();
}
function handleKeypairImport(event: Event): void {
const fileInput = event.target as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const keys = JSON.parse(event.target?.result as string);
await importAndSaveKeys(keys);
alert("Successfully imported keypair");
};
reader.readAsText(file);
}
function close(event: Event): void {
const dialog = (event.target as HTMLElement).closest(`#${MODAL_ID}`);
dialog?.removeAttribute("open");
}
export const initKeyManager = (target: HTMLElement | null) => {
const openKeyManagerEl = document.querySelector("#openKeyManager");
if (!target || !openKeyManagerEl) {
throw new Error("No target element found");
}
retrieveOrGenerateKeyPair();
openKeyManagerEl.addEventListener("click", (event: Event) => {
event.preventDefault();
ecdhPrivateKey.value = localStorage.getItem("ecdhPrivate");
ecdhPublicKey.value = localStorage.getItem("ecdhPublic");
document.querySelector(`#${MODAL_ID}`)?.setAttribute("open", "");
});
render(target, template);
};