Compare commits

...

7 Commits

40 changed files with 5872 additions and 304 deletions

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
API_BASE_URL=https://yeah.sure.dog
BASE_URL=https://sure.dog

51
.gitignore vendored
View File

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

View File

@@ -5,22 +5,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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>
<title>SURE DOG - Secure URL Requests</title>
<meta name="description" content="Securely request information via SURE links" />
<meta property="og:title" content="SURE - Secure URL Requests" />
<meta property="og:description" content="Securely request information via SURE links" />
<meta property="og:image" content="https://sure.dog/img/logo.png" />
<meta property="og:image:alt" content="Abstract logo of the letter S" />
<meta property="og:image:alt" content="A low poly cartoon portrait of a Border Collie wearing green shades" />
<meta property="og:locale" content="en_US" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
@@ -30,7 +21,6 @@
<link rel="icon" href="/favicon.ico" />
<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="apple-mobile-web-app-capable" content="yes" />
<meta name="application-name" content="SURE" />
@@ -39,24 +29,24 @@
<meta name="msapplication-navbutton-color" content="#44a616" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="msapplication-starturl" content="/" />
<style>
#js-warning {
display: none;
}
</style>
<noscript>
<style>
#js-warning {
display: block;
}
</style>
</noscript>
</head>
<body style="opacity: 0; background-color: #11191f;">
<header class="container">
<hgroup>
<h1>
SURE
</h1>
<p>
<span style="color: #44a616;">S</span>ecure
<span style="color: #44a616;">U</span>RL
<span style="color: #44a616;">Re</span>quests
</p>
</hgroup>
</header>
<!-- No JS Warning -->
<dialog open class="js-warning">
<body>
<header class="container" id="header"></header>
<dialog open id="js-warning">
<article>
<header>No Javascript Detected</header>
<p>
@@ -66,42 +56,9 @@
</p>
</article>
</dialog>
<main class="container">
<section>
<div id="app"></div>
<details>
<summary>How it Works:</summary>
<ul>
<li>Each client generates an ECDH keypair, consisting of a public key and a private key.</li>
<li>Your private key is kept in localStorage, and never leaves your device.</li>
<li>Your public key is embedded in the URLs you generate. This key can be safely shared anywhere without
compromising security.</li>
<li>When another client opens your generated URL, they will find your public ECDH key. They then generate a
random IV for this specific message, and use it, along with their private ECDH key and your public ECDH key,
to derive a shared secret (AES-GCM).</li>
<li>This derived shared secret never leaves their device. It is used to encrypt their message to you.
The encrypted message, along with their public key and the IV for this message, are embedded in the URL they
generate.</li>
<li>Upon opening the response URL, your device uses your private ECDH key, along with the public key and IV
from the URL, to recreate the shared secret. This secret is used to decrypt the message. If the message was
properly encrypted using the expected keys, it will be successfully decrypted and displayed to you.</li>
<li>If you clear your browser's local storage, you will not be able to decrypt any response URLs generated
with your previous unique URL.</li>
</ul>
</details>
</section>
</main>
<footer class="container">
<p>
<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>
</p>
</footer>
<div id="page"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

4765
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,26 @@
{
"name": "sure",
"private": true,
"version": "0.0.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build-standalone": "tsc && vite build --config vite.standalone.config.ts",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@types/workbox-precaching": "^5.0.0",
"sass": "^1.70.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-make-offline": "^1.0.0"
"vite-plugin-make-offline": "^1.0.0",
"vite-plugin-pwa": "^0.17.5",
"workbox-precaching": "^7.0.0"
},
"dependencies": {
"@picocss/pico": "^1.5.11"
"@picocss/pico": "^1.5.11",
"uhtml": "^4.4.7",
"workbox-window": "^7.0.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 B

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/img/flowchart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 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"
}
]
}

52
src/components/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>`;

39
src/components/Footer.ts Normal file
View File

@@ -0,0 +1,39 @@
import { html } from "uhtml";
import { openSettings } from "./settings/Settings";
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="#" @click=${openSettings}>Settings</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>
<p class="center">Server: <code>${import.meta.env.VITE_BASE_URL}</code></p>
<p class="center">API: <code>${import.meta.env.VITE_API_BASE_URL}</code></p>
</div>
</footer>`;

11
src/components/Header.ts Normal file
View File

@@ -0,0 +1,11 @@
import { html } from "uhtml";
export const Header = html` <hgroup>
<h1>
SURE<span class="primary"><a href="/">DOG</a></span>
</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>`;

25
src/components/Modal.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Hole, html } from "uhtml";
import { signal } from "uhtml/preactive";
type ModalContent = Hole | ((...args: any[]) => Hole);
const isModalOpen = signal(false);
const Content = signal<ModalContent | undefined>(undefined);
export const AppModal = () => html`<dialog ?open=${isModalOpen.value}>
<article class="container-fluid">${renderContent(Content.value)}</article>
</dialog>`;
const renderContent = (innerContent: ModalContent | undefined) => {
return typeof innerContent === "function"
? (innerContent as (...args: any[]) => Hole)()
: innerContent;
};
export const openModal = (innerContent: ModalContent) => {
Content.value = innerContent;
isModalOpen.value = true;
};
export const closeModal = () => {
isModalOpen.value = false;
Content.value = undefined;
};

70
src/components/Update.ts Normal file
View File

@@ -0,0 +1,70 @@
import { html } from "uhtml";
import { signal } from "uhtml/preactive";
import {
checkServiceWorkers,
deregisterServiceWorkers,
} from "./settings/Settings";
import { Workbox } from "workbox-window";
const open = signal(false);
const registration = signal<ServiceWorkerRegistration | undefined>(undefined);
export const UpdateModal = () => {
return html` <dialog ?open=${open.value}>
<article class="container-fluid">
<h3>An update is available!</h3>
<p>
A new version of the app is available! Click the button below to update
and reload. If you're stuck in an endless update loop, click Clear Cache
below to fix it.
</p>
<p>
<button aria-label="Update and reload" @click=${update}>Update</button>
</p>
<p>
<button
class="secondary"
aria-label="Clear Cache"
@click=${deregisterServiceWorkers}
>
Clear Cache
</button>
</p>
</article>
</dialog>`;
};
const update = async (event: Event) => {
event.preventDefault();
if (registration.value && registration.value.waiting) {
console.log("Service Worker is updating.");
registration.value.waiting.postMessage({ type: "SKIP_WAITING" });
} else {
console.error("Service Worker failed to update.");
}
};
export const checkForUpdates = async () => {
try {
if ("serviceWorker" in navigator) {
const wb = new Workbox("/sw.js");
wb.addEventListener("waiting", () => {
console.log("Service Worker is ready to handle offline usage.");
open.value = true;
});
wb.addEventListener("controlling", () => {
window.location.reload();
});
registration.value = await wb.register();
console.log("Service Worker has been registered.");
await checkServiceWorkers();
}
} catch (err) {
console.error("Failed to register Service Worker: ", err);
}
};

View File

@@ -0,0 +1,64 @@
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";
import { openSettings } from "./Settings.ts";
const ecdhPublicKey = signal<string | null>(null);
const ecdhPrivateKey = signal<string | null>(null);
export const KeyManager = () => html`
<h2>Key Manager</h2>
<p>
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 class="secondary" @click=${openSettings}>Back</button>
</p>`;
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);
}
export async function initKeyManager() {
await retrieveOrGenerateKeyPair();
ecdhPublicKey.value = localStorage.getItem(
LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY
);
ecdhPrivateKey.value = localStorage.getItem(
LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY
);
return true;
}

View File

@@ -0,0 +1,142 @@
import { html } from "uhtml";
import { signal } from "uhtml/preactive";
import { API_BASE_URL } from "../../utils/store.ts";
import { retrieveOrGenerateKeyPair } from "../../utils/crypto.ts";
import { openSettings } from "./Settings.ts";
const notificationPermission = signal(Notification.permission);
const pushSubscription = signal<PushSubscription | null>(null);
export const NotificationManager = () => html` <h2>Notifications</h2>
<p>
This is an <mark>experimental feature</mark> and may not work as expected.
It will not work in offline-mode, and requires subscribing to the VAPID Web
Push server at <code>${API_BASE_URL}</code> for it to send Push
Notifications to your device in the background.
</p>
<p>
If enabled, you will receive a notification whenever a user generates a URL
with a message to send to you. Upon clicking on the notification, it will
immediately take you to their generated URL and reveal the message.
</p>
<p>Notifications: <code>${notificationPermission.value}</code></p>
<p>Subscribed: <code>${pushSubscription.value !== null}</code></p>
<p>
<button
@click=${subscribeUserToPush}
?disabled=${notificationPermission.value === "granted" &&
pushSubscription.value !== null}
>
Enable Notifications
</button>
<button
@click=${unsubscribeUserFromPush}
?disabled=${pushSubscription.value === null}
>
Unsubscribe
</button>
<button class="secondary" @click=${openSettings}>Back</button>
</p>`;
export async function sendNotification(publicKey: CryptoKey, url: URL) {
const exportedKey = await window.crypto.subtle.exportKey("raw", publicKey);
const publicKeyString = btoa(
String.fromCharCode(...new Uint8Array(exportedKey))
);
await fetch(`${API_BASE_URL}api/send-notification`, {
method: "POST",
body: JSON.stringify({
publicKey: publicKeyString,
url: url.toString(),
}),
headers: {
"Content-Type": "application/json",
},
});
}
export async function checkPushSubscription() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
pushSubscription.value = subscription;
}
export async function unsubscribeUserFromPush() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
throw new Error("No subscription to unsubscribe");
}
const successful = await subscription.unsubscribe();
if (!successful) {
throw new Error("Failed to unsubscribe");
}
// await fetch(`${API_BASE_URL}api/unsubscribe`, {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({ subscription }),
// });
pushSubscription.value = null;
console.log("User is unsubscribed.");
} catch (err) {
console.error("Failed to unsubscribe the user: ", err);
}
}
export async function subscribeUserToPush(
event: Event | undefined = undefined
) {
event?.preventDefault();
try {
notificationPermission.value = Notification.permission;
if (notificationPermission.value !== "granted") {
const permission = await Notification.requestPermission();
notificationPermission.value = permission;
}
if (notificationPermission.value !== "granted") {
throw new Error(
`Notification permission not granted: ${notificationPermission.value}`
);
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
const response = await fetch(`${API_BASE_URL}api/public-key`);
const { publicKey: serverPublicKey } = await response.json();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: serverPublicKey,
});
}
const { publicKey: publicKey } = await retrieveOrGenerateKeyPair();
const exportedKey = await window.crypto.subtle.exportKey("raw", publicKey);
const exportedKeyString = btoa(
String.fromCharCode(...new Uint8Array(exportedKey))
);
await fetch(`${API_BASE_URL}api/subscribe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ subscription, publicKey: exportedKeyString }),
});
console.log("User is subscribed.");
checkPushSubscription();
} catch (err) {
console.error("Failed to subscribe the user: ", err);
}
}

View File

@@ -0,0 +1,56 @@
import { html } from "uhtml";
import { closeModal, openModal } from "../Modal.ts";
import { NotificationManager } from "./NotificationManager.ts";
import { KeyManager, initKeyManager } from "./KeyManager.ts";
import { hasServiceWorkers } from "../../utils/store.ts";
export const Settings = () => html`<h2>Settings</h2>
<section>
<button @click=${openKeyManager}>Manage Keys</button>
<button @click=${openNotificationManager}>Notifications</button>
<button
@click=${deregisterServiceWorkers}
?disabled=${!hasServiceWorkers.value}
>
Clear Cache
</button>
<button class="secondary" @click=${closeModal}>Close</button>
</section>`;
export function openSettings(event: Event) {
event.preventDefault;
openModal(Settings);
}
async function openKeyManager(event: Event) {
event.preventDefault();
await initKeyManager();
openModal(KeyManager);
}
async function openNotificationManager(event: Event) {
event.preventDefault();
openModal(NotificationManager);
}
export async function checkServiceWorkers() {
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
hasServiceWorkers.value = registrations.length > 0;
console.log("Service Workers registered:", hasServiceWorkers.value);
}
}
export async function deregisterServiceWorkers(event: Event) {
event.preventDefault();
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map((registration) => registration.unregister())
);
}
await checkServiceWorkers();
alert(
"Service Workers deregistered! If you think you're not seeing the latest version of the app, try refreshing the page."
);
}

View File

@@ -1,39 +1,40 @@
import "@picocss/pico/css/pico.min.css";
import "./style.css";
import { setupRequestPage } from "./routes/request.ts";
import { setupSendPage } from "./routes/send.ts";
import { setupReceivePage } from "./routes/receive.ts";
import "./style.scss";
import { page } from "./utils/store.ts";
import { Header } from "./components/Header.ts";
import { html } from "uhtml";
import { effect } from "uhtml/preactive";
import { reactive } from "uhtml/reactive";
import { Footer } from "./components/Footer.ts";
import { HowItWorks } from "./components/HowItWorks.ts";
import { FAQ } from "./components/FAQ.ts";
import { routeToPage, hashChange } from "./router.ts";
import { UpdateModal, checkForUpdates } from "./components/Update.ts";
import { subscribeUserToPush } from "./components/settings/NotificationManager.ts";
import { AppModal } from "./components/Modal.ts";
const start = () => {
const fragmentData = new URLSearchParams(window.location.hash.slice(1));
const appElement = document.querySelector<HTMLElement>("#app");
try {
const render = reactive(effect);
const template = () => html` ${AppModal()} ${UpdateModal()}
<main class="container">
<section>${routeToPage(page.value)}</section>
<div>${HowItWorks}</div>
<div>${FAQ}</div>
</main>
${Footer}`;
if (!appElement) {
throw new Error("No app element found");
}
// Listen for changes in the hash and update the state accordingly
window.addEventListener("hashchange", hashChange);
// If the URL contains the 'p', 'iv', and 'm' parameters, then the receiver page is set up
if (
fragmentData.has("p") &&
fragmentData.has("iv") &&
fragmentData.has("m")
) {
setupReceivePage(appElement, {
p: decodeURIComponent(fragmentData.get("p")!),
iv: decodeURIComponent(fragmentData.get("iv")!),
m: decodeURIComponent(fragmentData.get("m")!),
});
return;
}
// Initialize the app
hashChange();
checkForUpdates();
const headerElement = document.getElementById("header");
render(headerElement, Header);
// If the URL contains the 'p' parameter, then the sender page is set up
if (fragmentData.has("p")) {
setupSendPage(appElement, fragmentData.get("p")!);
return;
}
const appElement = document.getElementById("page");
render(appElement, template);
// Otherwise, the request page is set up
setupRequestPage(appElement);
};
start();
if (Notification.permission === "granted") subscribeUserToPush();
} catch (error) {
console.error(error);
}

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,38 +1,50 @@
import { html } from "uhtml";
import {
deriveSharedSecret,
decrypt,
retrieveOrGenerateKeyPair,
} from "../utils/crypto";
} from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
const MESSAGE_OUTPUT_ID = "message";
const TEMPLATE = `
<details open>
<summary>How To Use:</summary>
<ul>
<li>If someone used your unique request URL to generate this response URL, 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>
</details>
const decryptedData = signal("");
<form>
<textarea
id="${MESSAGE_OUTPUT_ID}"
readonly
aria-label="Decrypted Message"
></textarea>
</form>
`;
export const ReceivePage = () => {
return html`
<details open>
<summary>How To Use:</summary>
<ul>
<li>
If someone used your unique request URL to generate this response URL,
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>
</details>
export async function setupReceivePage(
element: HTMLElement,
params: { p: string; iv: string; m: string }
) {
element.innerHTML = TEMPLATE;
<form>
<textarea
id="${MESSAGE_OUTPUT_ID}"
readonly
aria-label="Decrypted Message"
.value=${decryptedData.value}
></textarea>
</form>
`;
};
decryptData(params);
}
async function decryptData({ p, iv, m }: { p: string; iv: string; m: string }) {
export async function decryptData({
p,
iv,
m,
}: {
p: string;
iv: string;
m: string;
}) {
// Parse the 'p' parameter to get publicB
const publicBJwk = JSON.parse(atob(p));
@@ -54,11 +66,5 @@ async function decryptData({ p, iv, m }: { p: string; iv: string; m: string }) {
const aesKey = await deriveSharedSecret(keyPairA.privateKey, publicB);
// Decrypt the message using the AES key and IV
const decryptedData = await decrypt(aesKey, iv, m);
// Update the message output with the decrypted message
const messageOutput = document.getElementById(
MESSAGE_OUTPUT_ID
) as HTMLTextAreaElement;
messageOutput.value = decryptedData;
decryptedData.value = await decrypt(aesKey, iv, m);
}

View File

@@ -1,45 +1,66 @@
import { html } from "uhtml";
import { retrieveOrGenerateKeyPair } from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
const URL_INPUT_ID = "request-url";
const TEMPLATE = `
<details open>
<summary>How To Use:</summary>
<ol>
<li><strong>Request:</strong> Copy and share the URL below with the person you want to receive data from.</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><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>
</details>
const COPY_BUTTON_ID = "copy-button";
const requestUrl = signal<undefined | URL>(undefined);
<form>
<input
type="text"
name="request-url"
id="${URL_INPUT_ID}"
placeholder=""
aria-label="Request URL"
required
/>
</form>
`;
export function RequestPage() {
return html`
<details open>
<summary><strong>How To Use:</strong></summary>
<ol>
<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>Only your browser can open the URL and decrypt the secret.</li>
</ol>
</details>
export async function setupRequestPage(element: HTMLElement) {
element.innerHTML = TEMPLATE;
const url = await generateUrl();
const input = document.getElementById(URL_INPUT_ID) as HTMLInputElement;
input.value = url.toString();
<form>
<input
type="text"
name="request-url"
.id=${URL_INPUT_ID}
.value=${requestUrl && requestUrl.toString()}
placeholder=""
aria-label="Request URL"
required
/>
<button
aria-label="Copy to Clipboard"
.id=${COPY_BUTTON_ID}
@click=${(event: Event) =>
copyToClipboard(event, requestUrl && requestUrl.toString())}
>
Copy to Clipboard
</button>
</form>
`;
}
async function generateUrl(): Promise<URL> {
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 url = new URL(window.location.toString());
url.search = "";
url.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
// Return the generated URL
return url;
async function copyToClipboard(event: Event, url: string) {
try {
event.preventDefault();
await navigator.clipboard.writeText(url);
alert("Copied to clipboard!");
} catch (err) {
console.error("Failed to copy to clipboard: ", err);
}
}
export async function generateRequestUrl() {
try {
const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair();
const ecdhPublicJwk = await window.crypto.subtle.exportKey(
"jwk",
ecdhPublic
);
const updatedUrl = new URL(window.location.toString());
updatedUrl.search = "";
updatedUrl.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
requestUrl.value = updatedUrl;
} catch (err) {
console.error("Failed to generate request URL: ", err);
}
}

View File

@@ -1,62 +1,85 @@
import { html } from "uhtml";
import {
deriveSharedSecret,
encrypt,
retrieveOrGenerateKeyPair,
} from "../utils/crypto";
} from "../utils/crypto.ts";
import { signal } from "uhtml/preactive";
import { LOCAL_STORAGE_KEYS } from "../utils/store.ts";
import { sendNotification } from "../components/settings/NotificationManager.ts";
const ENCRYPTED_URL_INPUT_ID = "encrypted-url";
const MESSAGE_INPUT_ID = "message";
const ENCRYPT_BUTTON_ID = "encrypt";
const REQUEST_PUBLIC_KEY = "requestPublicKey";
const TEMPLATE = `
<details open>
<summary>How To Use:</summary>
<ol>
<li>Enter the information you want to send back to the original requester into the text input below.</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>
const messageToEncrypt = signal("");
const encryptedUrl = signal("");
const isCopyButtonDisabled = signal(true);
<form>
<input
type="text"
name="message"
id="${MESSAGE_INPUT_ID}"
placeholder="Enter your message here..."
aria-label="Message to Encrypt"
required
/>
<input
id="${ENCRYPT_BUTTON_ID}"
type="submit"
value="Generate Response"
aria-label="Generate Response"
/>
<input
type="text"
name="encrypted-url"
id="${ENCRYPTED_URL_INPUT_ID}"
placeholder="URL with your encrypted response will appear here..."
aria-label="Encrypted URL"
/>
</form>
`;
export const SendPage = () => {
return html`
<details open>
<summary>How To Use:</summary>
<ol>
<li>
Enter the information you want to send back to the original requester
into the text input below.
</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"
placeholder="Enter your message here..."
aria-label="Messag
to Encrypt"
required
value=${messageToEncrypt.value}
@input=${(e: Event) => {
messageToEncrypt.value = (e.target as HTMLInputElement).value;
}}
/>
<input
type="submit"
value="Generate Response"
aria-label="Generate Response"
@click=${encryptData}
/>
<input
type="text"
name="encrypted-url"
placeholder="URL with your encrypted response
will appear here..."
aria-label="Encrypted URL"
value=${encryptedUrl.value}
/>
<button
aria-label="Copy to Clipboard"
@click=${copyToClipboard}
disabled=${isCopyButtonDisabled.value}
>
Copy to Clipboard
</button>
</form>
`;
};
export async function setupSendPage(element: HTMLElement, key: string) {
element.innerHTML = TEMPLATE;
localStorage.setItem(REQUEST_PUBLIC_KEY, key);
// Add an event listener to the "Encrypt" button
const encryptButton = document.getElementById(
ENCRYPT_BUTTON_ID
) as HTMLButtonElement;
encryptButton.addEventListener("click", encryptData);
async function copyToClipboard(event: Event) {
event.preventDefault();
await navigator.clipboard.writeText(encryptedUrl.value);
alert(
"Copied to clipboard! Send this URL to the requester, or they will be notified via a Push Notification if they have it enabled."
);
}
async function encryptData(event: Event) {
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
const publicAJwk = JSON.parse(atob(key));
@@ -69,27 +92,17 @@ async function encryptData(event: Event) {
name: "ECDH",
namedCurve: "P-256",
},
false,
true,
[]
);
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
const aesKey = await deriveSharedSecret(keyPairB.privateKey, publicA);
// Encrypt the message input value using the AES key
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 { encryptedData, iv } = await encrypt(aesKey, messageToEncrypt.value);
const ecdhPublicJwk = await window.crypto.subtle.exportKey(
"jwk",
@@ -107,5 +120,8 @@ async function encryptData(event: Event) {
)
)}`;
encryptedUrlInput.value = url.toString();
sendNotification(publicA, url);
encryptedUrl.value = url.toString();
isCopyButtonDisabled.value = false;
}

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/sw.js Normal file
View File

@@ -0,0 +1,52 @@
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
// This array will be injected with the actual file manifest
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// Receive push notifications
self.addEventListener("push", function (e) {
if (!(self.Notification && self.Notification.permission === "granted")) {
//notifications aren't supported or permission not granted!
return;
}
if (e.data) {
let message = e.data.json();
e.waitUntil(
self.registration.showNotification("SURE DOG", {
body: message.body,
icon: "img/logo.png",
data: message.data,
})
);
}
});
self.addEventListener("notificationclick", (e) => {
// Close the notification popout
e.notification.close();
// Get all the Window clients
e.waitUntil(
clients.matchAll({ type: "window" }).then((clientsArr) => {
// If a Window tab matching the targeted URL already exists, focus that;
const hadWindowToFocus = clientsArr.some((windowClient) =>
windowClient.url === e.notification.data.url
? (windowClient.focus(), true)
: false
);
// Otherwise, open a new tab to the applicable URL and focus it.
if (!hadWindowToFocus)
clients
.openWindow(e.notification.data.url)
.then((windowClient) => (windowClient ? windowClient.focus() : null));
})
);
});
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});

View File

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

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

@@ -0,0 +1,23 @@
import { signal } from "uhtml/preactive";
import { Route } from "../router";
export const API_BASE_URL = new URL(
import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"
);
export const BASE_URL = new URL(
import.meta.env.VITE_BASE_URL || "http://localhost:3000"
);
// 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",
}
export const hasServiceWorkers = signal(false);

6
src/utils/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BASE_URL?: string;
readonly VITE_API_BASE_URL?: string;
}

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

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

1
src/vite-env.d.ts vendored
View File

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

View File

@@ -1,6 +1,79 @@
import { defineConfig } from "vite";
import { makeOffline } from "vite-plugin-make-offline";
import { version } from "./package.json";
import { VitePWA } from "vite-plugin-pwa";
const isProduction = process.env.NODE_ENV === "production";
const API_BASE_URL = new URL(
process.env.VITE_API_BASE_URL || "http://localhost:3000"
);
const API_BASE_URL_SECURE = API_BASE_URL.protocol === "https:";
const BASE_URL = new URL(process.env.VITE_BASE_URL || "http://localhost:5137");
const BASE_URL_SECURE = BASE_URL.protocol === "https:";
export default defineConfig({
plugins: [makeOffline()], // This is the plugin 😃
server: {
proxy: {
"/ws": {
target: API_BASE_URL_SECURE
? `wss://${API_BASE_URL.hostname}`
: `ws://${API_BASE_URL.hostname}`,
ws: true,
changeOrigin: true,
secure: isProduction,
},
"/api": {
target: BASE_URL_SECURE
? `https://${BASE_URL.hostname}`
: `http://${BASE_URL.hostname}`,
changeOrigin: true,
secure: isProduction,
},
},
},
define: {
"process.env.PACKAGE_VERSION": JSON.stringify(version),
"process.env.BUILD_TIME": JSON.stringify(new Date().toISOString()),
},
publicDir: "public",
plugins: [
VitePWA({
srcDir: "src",
filename: "sw.js",
registerType: "prompt",
includeAssets: [
"**/*.png",
"**/*.jpg",
"**/*.jpeg",
"**/*.svg",
"**/*.gif",
],
strategies: "injectManifest",
workbox: {
globPatterns: ["**/*"],
globDirectory: "dist",
},
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",
},
],
},
}),
],
});

View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { makeOffline } from "vite-plugin-make-offline";
export default defineConfig({
plugins: [makeOffline()],
});