set up service worker caching, fix perf issues with rerenders, split code into more modules, switch to a green theme, add modal for updating site via service worker

This commit is contained in:
Silas 2024-02-02 17:25:32 -05:00
parent ccfe6c60cd
commit ea4c8a3a30
Signed by: silentsilas
GPG Key ID: 4199EFB7DAA34349
28 changed files with 5278 additions and 480 deletions

51
.gitignore vendored
View File

@ -1,25 +1,26 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local dev-dist
*.local
# Editor directories and files
.vscode/* # Editor directories and files
!.vscode/extensions.json .vscode/*
.idea !.vscode/extensions.json
.DS_Store .idea
*.suo .DS_Store
*.ntvs* *.suo
*.njsproj *.ntvs*
*.sln *.njsproj
*.sw? *.sln
.vercel *.sw?
.vercel

View File

@ -7,15 +7,6 @@
<title>SURE - Secure URL Requests</title> <title>SURE - Secure URL Requests</title>
<script type="module">
document.documentElement.classList.remove("no-js");
document.documentElement.classList.add("js");
window.onload = function () {
document.body.style.opacity = "1";
}
</script>
<meta name="description" content="Securely request information via SURE links" /> <meta name="description" content="Securely request information via SURE links" />
<meta property="og:title" content="SURE - Secure URL Requests" /> <meta property="og:title" content="SURE - Secure URL Requests" />
<meta property="og:description" content="Securely request information via SURE links" /> <meta property="og:description" content="Securely request information via SURE links" />
@ -30,7 +21,6 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="application-name" content="SURE" /> <meta name="application-name" content="SURE" />
@ -39,24 +29,24 @@
<meta name="msapplication-navbutton-color" content="#44a616" /> <meta name="msapplication-navbutton-color" content="#44a616" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="msapplication-starturl" content="/" /> <meta name="msapplication-starturl" content="/" />
<style>
#js-warning {
display: none;
}
</style>
<noscript>
<style>
#js-warning {
display: block;
}
</style>
</noscript>
</head> </head>
<body style="opacity: 0;"> <body>
<header class="container"> <header class="container" id="header"></header>
<hgroup> <dialog open id="js-warning">
<h1>
SURE
</h1>
<h3>
<span style="color: #1095C1;">S</span>ecure
<span style="color: #1095C1;">U</span>RL
<span style="color: #1095C1;">Re</span>quests
</h3>
</hgroup>
</header>
<!-- No JS Warning -->
<dialog open class="js-warning">
<article> <article>
<header>No Javascript Detected</header> <header>No Javascript Detected</header>
<p> <p>
@ -66,85 +56,7 @@
</p> </p>
</article> </article>
</dialog> </dialog>
<div id="page"></div>
<div id="keyManagerContainer"></div>
<main class="container">
<section>
<div id="app"></div>
<details>
<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>
<li>
<strong>What is this?</strong>
<p>
SURE is a system for securely sending information via a URL request. It uses the Elliptic Curve
Diffie-Hellman algorithm to generate a shared secret between the sender and recipient, which is then used
to encrypt the message in a response URL. The device that generated the URL request can then decrypt the
message in the URL sent back to them using their private key.
</p>
</li>
<li>
<strong>What if I want all of my devices to be able to decrypt messages from the same request?</strong>
<p>
If you want to be able to decrypt messages from the same request on multiple devices, you'll need to
export the keypair from the device that generated the request, and then import it into the other
devices. You can do this by clicking the "Manage Keys" link at the bottom of the page, and then clicking
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>
</details>
</section>
</main>
<footer class="container">
<div style="width: fit-content; margin: auto;">
<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="#" id="openKeyManager">Manage Keys</a>
</div>
</footer>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

4601
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,20 @@
{ {
"name": "sure", "name": "sure",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build-standalone": "tsc && vite build --config vite.standalone.config.ts",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"sass": "^1.70.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-plugin-make-offline": "^1.0.0" "vite-plugin-make-offline": "^1.0.0",
"vite-plugin-pwa": "^0.17.5"
}, },
"dependencies": { "dependencies": {
"@picocss/pico": "^1.5.11", "@picocss/pico": "^1.5.11",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -1,21 +0,0 @@
{
"name": "SURE",
"short_name": "SURE",
"theme_color": "#44a616",
"background_color": "#3d006e",
"display": "minimal-ui",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@ -1,66 +1,34 @@
import "@picocss/pico/css/pico.min.css"; import "./style.scss";
import "./style.css"; import { KeyManager } from "./template/keyManager.ts";
import { setupRequestPage } from "./routes/request.ts"; import { Header } from "./template/header.ts";
import { setupSendPage } from "./routes/send.ts"; import { html } from "uhtml";
import { setupReceivePage } from "./routes/receive.ts"; import { effect } from "uhtml/preactive";
import { initKeyManager } from "./utils/keyManager.ts"; import { reactive } from "uhtml/reactive";
import { Footer } from "./template/footer.ts";
import { HowItWorks } from "./template/howitworks.ts";
import { FAQ } from "./template/faq.ts";
import { page } from "./utils/store.ts";
import { routeToPage, hashChange } from "./router.ts";
import { UpdateModal, checkForUpdates } from "./template/update.ts";
type PageState = { const render = reactive(effect);
page: string; checkForUpdates();
params: URLSearchParams; const template = () => html` ${KeyManager()} ${UpdateModal()}
}; <main class="container">
<section>${routeToPage(page.value)}</section>
let state: PageState = { <div>${HowItWorks}</div>
page: "request", <div>${FAQ}</div>
params: new URLSearchParams(window.location.hash.slice(1)), </main>
}; ${Footer}`;
const renderPage = () => {
const appElement = document.querySelector<HTMLElement>("#app");
if (!appElement) {
throw new Error("No app element found");
}
switch (state.page) {
case "request":
setupRequestPage(appElement);
break;
case "send":
setupSendPage(appElement, state.params.get("p")!);
break;
case "receive":
setupReceivePage(appElement, {
p: decodeURIComponent(state.params.get("p")!),
iv: decodeURIComponent(state.params.get("iv")!),
m: decodeURIComponent(state.params.get("m")!),
});
break;
}
};
// Define a function to update the state and re-render
const setState = (newState: PageState) => {
state = { ...state, ...newState };
renderPage();
};
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 });
}
};
// Listen for changes in the hash and update the state accordingly // Listen for changes in the hash and update the state accordingly
window.addEventListener("hashchange", update); window.addEventListener("hashchange", hashChange);
// Initialize the app // Initialize the app
update(); hashChange();
renderPage();
initKeyManager(document.querySelector("#keyManagerContainer")); const headerElement = document.getElementById("header");
render(headerElement, Header);
const appElement = document.getElementById("page");
render(appElement, template);

43
src/router.ts Normal file
View File

@ -0,0 +1,43 @@
import { Hole } from "uhtml";
import { RequestPage, generateRequestUrl } from "./routes/request.ts";
import { SendPage } from "./routes/send.ts";
import { ReceivePage, decryptData } from "./routes/receive.ts";
import { NotFoundPage } from "./routes/notfound.ts";
import { LOCAL_STORAGE_KEYS, page, params } from "./utils/store.ts";
export enum Route {
Request = "request",
Send = "send",
Receive = "receive",
}
export const routeToPage = (toPage: Route): Hole => {
switch (toPage) {
case Route.Request:
return RequestPage();
case Route.Send:
return SendPage();
case Route.Receive:
return ReceivePage();
default:
return NotFoundPage;
}
};
export const hashChange = () => {
const updatedParams = new URLSearchParams(window.location.hash.slice(1));
params.value = updatedParams;
const p = params.value.get("p");
const iv = params.value.get("iv");
const m = params.value.get("m");
if (p && iv && m) {
decryptData({ p, iv, m });
page.value = Route.Receive;
} else if (p) {
localStorage.setItem(LOCAL_STORAGE_KEYS.REQUEST_PUBLIC_KEY, p);
page.value = Route.Send;
} else {
generateRequestUrl();
page.value = Route.Request;
}
};

9
src/routes/notfound.ts Normal file
View File

@ -0,0 +1,9 @@
import { html } from "uhtml";
export const NotFoundPage = html`
<h2>You Lost, Dog?</h2>
<p>
I'm really not sure how you got here. But this is my spot; squatter's rights
and all. So please, <a href="/">go take a hike</a>.
</p>
`;

View File

@ -1,60 +1,50 @@
import { html, render } from "uhtml"; import { html } from "uhtml";
import { import {
deriveSharedSecret, deriveSharedSecret,
decrypt, decrypt,
retrieveOrGenerateKeyPair, retrieveOrGenerateKeyPair,
} from "../utils/crypto"; } from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
const MESSAGE_OUTPUT_ID = "message"; const MESSAGE_OUTPUT_ID = "message";
const decryptedData = signal("");
type PageState = { export const ReceivePage = () => {
decryptedData: string; return html`
}; <details open>
let state: PageState = { <summary>How To Use:</summary>
decryptedData: "", <ul>
}; <li>
// Move the template to a `uhtml` function If someone used your unique request URL to generate this response URL,
const Template = (state: PageState) => html` you should see the decrypted message below.
<details open> </li>
<summary>How To Use:</summary> <li>
<ul> Be sure to open this from the same browser you used to generate the
<li> original request URL.
If someone used your unique request URL to generate this response URL, </li>
you should see the decrypted message below. </ul>
</li> </details>
<li>
Be sure to open this from the same browser you used to generate the
original request URL.
</li>
</ul>
</details>
<form> <form>
<textarea <textarea
id="${MESSAGE_OUTPUT_ID}" id="${MESSAGE_OUTPUT_ID}"
readonly readonly
aria-label="Decrypted Message" aria-label="Decrypted Message"
.value=${state.decryptedData} .value=${decryptedData.value}
></textarea> ></textarea>
</form> </form>
`; `;
export async function setupReceivePage(
element: HTMLElement,
params: { p: string; iv: string; m: string }
) {
decryptData(params, element);
}
const setState = (newState: PageState, element: HTMLElement) => {
state = { ...state, ...newState };
render(element, Template(state));
}; };
async function decryptData( export async function decryptData({
{ p, iv, m }: { p: string; iv: string; m: string }, p,
element: HTMLElement iv,
) { m,
}: {
p: string;
iv: string;
m: string;
}) {
// 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));
@ -76,9 +66,5 @@ async function decryptData(
const aesKey = await deriveSharedSecret(keyPairA.privateKey, publicB); const aesKey = await deriveSharedSecret(keyPairA.privateKey, publicB);
// Decrypt the message using the AES key and IV // Decrypt the message using the AES key and IV
const decryptedData = await decrypt(aesKey, iv, m); decryptedData.value = await decrypt(aesKey, iv, m);
// Update the message output with the decrypted message
setState({ decryptedData }, element);
render(element, Template(state));
} }

View File

@ -1,14 +1,15 @@
import { html, render } from "uhtml"; import { html } from "uhtml";
import { retrieveOrGenerateKeyPair } from "../utils/crypto.ts"; import { retrieveOrGenerateKeyPair } from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
const URL_INPUT_ID = "request-url"; const URL_INPUT_ID = "request-url";
const COPY_BUTTON_ID = "copy-button"; const COPY_BUTTON_ID = "copy-button";
const requestUrl = signal(new URL("https://sure.dog/"));
export async function setupRequestPage(element: HTMLElement) { export function RequestPage() {
const url = await generateUrl(); return html`
const template = html`
<details open> <details open>
<summary>How To Use:</summary> <summary><strong>How To Use:</strong></summary>
<ol> <ol>
<li>Send the link below to someone you want to get a secret from.</li> <li>Send the link below to someone you want to get a secret from.</li>
<li>They add their secret and share the URL the app generates.</li> <li>They add their secret and share the URL the app generates.</li>
@ -21,7 +22,7 @@ export async function setupRequestPage(element: HTMLElement) {
type="text" type="text"
name="request-url" name="request-url"
.id=${URL_INPUT_ID} .id=${URL_INPUT_ID}
.value=${url.toString()} .value=${requestUrl.toString()}
placeholder="" placeholder=""
aria-label="Request URL" aria-label="Request URL"
required required
@ -29,14 +30,13 @@ export async function setupRequestPage(element: HTMLElement) {
<button <button
aria-label="Copy to Clipboard" aria-label="Copy to Clipboard"
.id=${COPY_BUTTON_ID} .id=${COPY_BUTTON_ID}
@click=${(event: Event) => copyToClipboard(event, url.toString())} @click=${(event: Event) =>
copyToClipboard(event, requestUrl.toString())}
> >
Copy to Clipboard Copy to Clipboard
</button> </button>
</form> </form>
`; `;
render(element, template);
} }
async function copyToClipboard(event: Event, url: string) { async function copyToClipboard(event: Event, url: string) {
@ -45,11 +45,11 @@ async function copyToClipboard(event: Event, url: string) {
alert("Copied to clipboard!"); alert("Copied to clipboard!");
} }
async function generateUrl(): Promise<URL> { export async function generateRequestUrl() {
const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair(); const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair();
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 updatedUrl = new URL(window.location.toString());
url.search = ""; updatedUrl.search = "";
url.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`; updatedUrl.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
return url; requestUrl.value = updatedUrl;
} }

View File

@ -1,109 +1,82 @@
import { html, render } from "uhtml"; import { html } from "uhtml";
import { import {
deriveSharedSecret, deriveSharedSecret,
encrypt, encrypt,
retrieveOrGenerateKeyPair, retrieveOrGenerateKeyPair,
} from "../utils/crypto"; } from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
import { LOCAL_STORAGE_KEYS } from "../utils/store.ts";
const ENCRYPTED_URL_INPUT_ID = "encrypted-url"; const messageToEncrypt = signal("");
const MESSAGE_INPUT_ID = "message"; const encryptedUrl = signal("");
const ENCRYPT_BUTTON_ID = "encrypt"; const isCopyButtonDisabled = signal(true);
const REQUEST_PUBLIC_KEY = "requestPublicKey";
const COPY_BUTTON_ID = "copy-button";
type PageState = { export const SendPage = () => {
message: string; return html`
encryptedUrl: string; <details open>
isCopyButtonDisabled: boolean; <summary>How To Use:</summary>
container: HTMLElement | null; <ol>
}; <li>
const state: PageState = { Enter the information you want to send back to the original requester
message: "", into the text input below.
encryptedUrl: "", </li>
isCopyButtonDisabled: true, <li>
container: null, Click on 'Generate Response'. This will create a new URL that contains
}; your encrypted message.
</li>
const update = (element: HTMLElement) => { <li>
// Re-render the component whenever the state changes Send the newly generated URL back to the original requester. Only
render(element, App(state)); their browser will be able to decrypt the message.
}; </li>
</ol>
const App = (state: PageState) => html` </details>
<details open> <form>
<summary>How To Use:</summary> <input
<ol> type="text"
<li> name="message"
Enter the information you want to send back to the original requester placeholder="Enter your message here..."
into the text input below. aria-label="Messag
</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>
</details>
<form>
<input
type="text"
name="message"
.id=${MESSAGE_INPUT_ID}
placeholder="Enter your message here..."
aria-label="Messag
to Encrypt" to Encrypt"
required required
value=${state.message} value=${messageToEncrypt.value}
@input=${(e: Event) => { @input=${(e: Event) => {
state.message = (e.target as HTMLInputElement).value; messageToEncrypt.value = (e.target as HTMLInputElement).value;
update(state.container!); }}
}} />
/> <input
<input type="submit"
.id=${ENCRYPT_BUTTON_ID} value="Generate Response"
type="submit" aria-label="Generate Response"
value="Generate Response" @click=${encryptData}
aria-label="Generate Response" />
@click=${encryptData} <input
/> type="text"
<input name="encrypted-url"
type="text" placeholder="URL with your encrypted response
name="encrypted-url"
.id=${ENCRYPTED_URL_INPUT_ID}
placeholder="URL with your encrypted response
will appear here..." will appear here..."
aria-label="Encrypted URL" aria-label="Encrypted URL"
value=${state.encryptedUrl} value=${encryptedUrl.value}
/> />
<button <button
aria-label="Copy to Clipboard" aria-label="Copy to Clipboard"
.id=${COPY_BUTTON_ID} @click=${copyToClipboard}
@click=${copyToClipboard} disabled=${isCopyButtonDisabled.value}
disabled=${state.isCopyButtonDisabled} >
> Copy to Clipboard
Copy to Clipboard </button>
</button> </form>
</form> `;
`; };
export async function setupSendPage(element: HTMLElement, key: string) {
localStorage.setItem(REQUEST_PUBLIC_KEY, key);
state.container = element;
update(element);
}
async function copyToClipboard(event: Event) { async function copyToClipboard(event: Event) {
event.preventDefault(); event.preventDefault();
await navigator.clipboard.writeText(state.encryptedUrl); await navigator.clipboard.writeText(encryptedUrl.value);
alert("Copied to clipboard! Send this URL to the requester."); alert("Copied to clipboard! Send this URL to the requester.");
} }
async function encryptData(event: Event) { async function encryptData(event: Event) {
event.preventDefault(); event.preventDefault();
const key = localStorage.getItem(REQUEST_PUBLIC_KEY)!; const key = localStorage.getItem(LOCAL_STORAGE_KEYS.REQUEST_PUBLIC_KEY)!;
// Parse the 'p' parameter to get publicA // Parse the 'p' parameter to get publicA
const publicAJwk = JSON.parse(atob(key)); const publicAJwk = JSON.parse(atob(key));
@ -126,7 +99,7 @@ async function encryptData(event: Event) {
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, messageToEncrypt.value);
const ecdhPublicJwk = await window.crypto.subtle.exportKey( const ecdhPublicJwk = await window.crypto.subtle.exportKey(
"jwk", "jwk",
@ -144,7 +117,6 @@ async function encryptData(event: Event) {
) )
)}`; )}`;
state.encryptedUrl = url.toString(); encryptedUrl.value = url.toString();
state.isCopyButtonDisabled = false; isCopyButtonDisabled.value = false;
update(state.container!);
} }

View File

@ -1,11 +0,0 @@
.js-warning {
display: none;
}
.no-js .js-warning {
display: flex;
}
body {
transition: opacity 1s ease-in-out;
}

52
src/style.scss Normal file
View File

@ -0,0 +1,52 @@
@import "@picocss/pico/scss/pico";
.primary {
color: #348037;
font-weight: bold;
&:hover {
color: darken(#348037, 10%);
}
}
.center {
width: fit-content;
margin: auto;
}
/* Green Light scheme (Default) */
/* Can be forced with data-theme="light" */
[data-theme="light"],
:root:not([data-theme="dark"]) {
--primary: #348037;
--primary-hover: #265f29;
--primary-focus: rgba(39, 92, 42, 0.125);
--primary-inverse: #FFF;
}
/* Green Dark scheme (Auto) */
/* Automatically enabled if user has Dark mode enabled */
@media only screen and (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--primary: #43a047;
--primary-hover: #4caf50;
--primary-focus: rgba(67, 160, 71, 0.25);
--primary-inverse: #FFF;
}
}
/* Green Dark scheme (Forced) */
/* Enabled if forced with data-theme="dark" */
[data-theme="dark"] {
--primary: #43a047;
--primary-hover: #4caf50;
--primary-focus: rgba(67, 160, 71, 0.25);
--primary-inverse: #FFF;
}
/* Green (Common styles) */
:root {
--form-element-active-border-color: var(--primary);
--form-element-focus-color: var(--primary-focus);
--switch-color: var(--primary-inverse);
--switch-checked-background-color: var(--primary);
}

52
src/template/faq.ts Normal file
View File

@ -0,0 +1,52 @@
import { html } from "uhtml";
export const FAQ = html`<details>
<summary><strong>FAQ</strong></summary>
<ul>
<li>
<strong>What is this?</strong>
<p>
SURE is a system for securely sending information via a URL request. It
uses the Elliptic Curve Diffie-Hellman algorithm to generate a shared
secret between the sender and recipient, which is then used to encrypt
the message in a response URL. The device that generated the URL request
can then decrypt the message in the URL sent back to them using their
private key.
</p>
</li>
<li>
<strong
>What if I want all of my devices to be able to decrypt messages from
the same request?</strong
>
<p>
If you want to be able to decrypt messages from the same request on
multiple devices, you'll need to export the keypair from the device that
generated the request, and then import it into the other devices. You
can do this by clicking the "Manage Keys" link at the bottom of the
page, and then clicking 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.
</p>
</li>
</ul>
</details>`;

45
src/template/footer.ts Normal file
View File

@ -0,0 +1,45 @@
import { html } from "uhtml";
import { openKeyManager } from "./keyManager.ts";
export const Footer = html`<footer class="container">
<div>
<p class="center">
<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="#"
id="openKeyManager"
@click=${(event: Event) => {
event.preventDefault();
openKeyManager();
}}
>Manage Keys</a
>
</p>
<p class="center">Version ${process.env.PACKAGE_VERSION}</p>
<p class="center">
Last updated on
${new Date(process.env.BUILD_TIME as any).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
})}
</p>
</div>
</footer>`;

9
src/template/header.ts Normal file
View File

@ -0,0 +1,9 @@
import { html } from "uhtml";
export const Header = html` <hgroup>
<h1>SURE</h1>
<h2>
<span class="primary">S</span>ecure <span class="primary">U</span>RL
<span class="primary">Re</span>quests
</h2>
</hgroup>`;

View File

@ -0,0 +1,43 @@
import { html } from "uhtml";
export const HowItWorks = html`<details>
<summary><strong>How it Works:</strong></summary>
<p>The first thing to understand is how public key cryptography works.</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
width="100%"
height="auto"
loading="lazy"
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
width="100%"
height="auto"
loading="lazy"
src="/img/flowchart.png"
alt="Flowchart of the SURE process"
/>
</details>`;

View File

@ -0,0 +1,77 @@
import { html } from "uhtml";
import {
exportKeys,
importAndSaveKeys,
retrieveOrGenerateKeyPair,
} from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
import { LOCAL_STORAGE_KEYS } from "../utils/store.ts";
const ecdhPublicKey = signal(
localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY)
);
const ecdhPrivateKey = signal(
localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY)
);
const isDialogOpen = signal(false);
export const KeyManager =
() => html`<dialog class="key-manager" ?open=${isDialogOpen.value}>
<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(event: Event): void {
const target = event.target as HTMLElement;
const el = target.nextElementSibling as HTMLInputElement;
el.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(): void {
isDialogOpen.value = false;
}
export async function openKeyManager() {
await retrieveOrGenerateKeyPair();
ecdhPublicKey.value = localStorage.getItem(
LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY
);
ecdhPrivateKey.value = localStorage.getItem(
LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY
);
isDialogOpen.value = true;
}

52
src/template/update.ts Normal file
View File

@ -0,0 +1,52 @@
import { html } from "uhtml";
import { signal } from "uhtml/preactive";
import { registerSW } from "virtual:pwa-register";
const open = signal(false);
const updateSW = signal<
((reloadPage?: boolean | undefined) => Promise<void>) | undefined
>(undefined);
export const UpdateModal = () => {
return html` <dialog ?open=${open.value}>
<article>
<header>An update is available!</header>
<p>
A new version of the app is available. Click the button below to update
and reload.
</p>
<footer>
<button aria-label="Update and reload" @click=${update}>Update</button>
</footer>
</article>
</dialog>`;
};
const update = (event: Event) => {
event.preventDefault();
if (updateSW.value) {
console.log("Service Worker is updating.");
updateSW.value(true);
} else {
console.error("Service Worker failed to update.");
}
};
export const checkForUpdates = () => {
if ("serviceWorker" in navigator) {
const promise = registerSW({
onNeedRefresh() {
console.log("Service Worker is ready to update.");
open.value = true;
},
onRegisteredSW() {
console.log("Service Worker has been registered.");
},
onOfflineReady() {
console.log("Service Worker is ready to handle offline usage.");
},
});
updateSW.value = promise;
}
};

View File

@ -1,3 +1,5 @@
import { LOCAL_STORAGE_KEYS } from "./store";
export async function generateKeyPair(): Promise<CryptoKeyPair> { export async function generateKeyPair(): Promise<CryptoKeyPair> {
return await window.crypto.subtle.generateKey( return await window.crypto.subtle.generateKey(
{ {
@ -34,11 +36,15 @@ export async function retrieveOrGenerateKeyPair(): Promise<CryptoKeyPair> {
let ecdhPrivate: CryptoKey; let ecdhPrivate: CryptoKey;
if ( if (
localStorage.getItem("ecdhPublic") && localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY) &&
localStorage.getItem("ecdhPrivate") localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY)
) { ) {
const ecdhPublicJwk = JSON.parse(localStorage.getItem("ecdhPublic")!); const ecdhPublicJwk = JSON.parse(
const ecdhPrivateJwk = JSON.parse(localStorage.getItem("ecdhPrivate")!); localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY)!
);
const ecdhPrivateJwk = JSON.parse(
localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY)!
);
ecdhPrivate = await window.crypto.subtle.importKey( ecdhPrivate = await window.crypto.subtle.importKey(
"jwk", "jwk",
@ -78,8 +84,14 @@ 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(
localStorage.setItem("ecdhPrivate", JSON.stringify(ecdhPrivateJwk)); LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY,
JSON.stringify(ecdhPublicJwk)
);
localStorage.setItem(
LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY,
JSON.stringify(ecdhPrivateJwk)
);
return { return {
public: JSON.stringify(ecdhPublicJwk), public: JSON.stringify(ecdhPublicJwk),
@ -116,7 +128,6 @@ export async function decrypt(
iv: string, iv: string,
encryptedData: string encryptedData: string
): Promise<string> { ): Promise<string> {
console.log(iv);
// Decode the iv and encryptedData from base64 // Decode the iv and encryptedData from base64
const ivUint8Array = base64ToUint8Array(iv); const ivUint8Array = base64ToUint8Array(iv);
const encryptedDataUint8Array = base64ToUint8Array(encryptedData); const encryptedDataUint8Array = base64ToUint8Array(encryptedData);

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);
};

14
src/utils/store.ts Normal file
View File

@ -0,0 +1,14 @@
import { signal } from "uhtml/preactive";
import { Route } from "../router";
// the current page / params for this URL
export const page = signal(Route.Receive);
export const params = signal(
new URLSearchParams(window.location.hash.slice(1))
);
export enum LOCAL_STORAGE_KEYS {
REQUEST_PUBLIC_KEY = "requestPublicKey",
ECDH_PUBLIC_KEY = "ecdhPublic",
ECDH_PRIVATE_KEY = "ecdhPrivate",
}

1
src/utils/vite-plugin-pwa.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite-plugin-pwa/client" />

View File

@ -1,6 +1,39 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { makeOffline } from "vite-plugin-make-offline"; import { VitePWA } from "vite-plugin-pwa";
import { version } from "./package.json";
export default defineConfig({ export default defineConfig({
plugins: [makeOffline()], // This is the plugin 😃 define: {
"process.env.PACKAGE_VERSION": JSON.stringify(version),
"process.env.BUILD_TIME": JSON.stringify(new Date().toISOString()),
},
plugins: [
VitePWA({
devOptions: {
enabled: true,
},
includeAssets: ["**/*.png", "**/*.ico", "**/*.webmanifest"],
manifest: {
name: "SURE",
short_name: "SURE",
theme_color: "#44a616",
background_color: "#3d006e",
display: "minimal-ui",
scope: "/",
start_url: "/",
icons: [
{
src: "/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
},
}),
],
}); });

35
vite.standalone.config.ts Normal file
View File

@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import { makeOffline } from "vite-plugin-make-offline";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
makeOffline(),
VitePWA({
devOptions: {
enabled: true,
},
manifest: {
name: "SURE",
short_name: "SURE",
theme_color: "#44a616",
background_color: "#3d006e",
display: "minimal-ui",
scope: "/",
start_url: "/",
icons: [
{
src: "/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
},
}),
],
});