diff --git a/bun.lockb b/bun.lockb index 31c7c8fa..ce762901 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dist/index.js b/dist/index.js index e7e41069..fb04f1bd 100644 --- a/dist/index.js +++ b/dist/index.js @@ -106,6 +106,9 @@ }); this.callSubscriptions(); } + clear() { + this.remove(...this.value.values()); + } // handlers handleAddition(handler) { this.additionHandlers.add(handler); @@ -211,10 +214,14 @@ break; } case "toggle": { - const state = value; - state.subscribe( - (newValue) => element.toggleAttribute(directiveValue, newValue) - ); + if (value.subscribe) { + const state = value; + state.subscribe( + (newValue) => element.toggleAttribute(directiveValue, newValue) + ); + } else { + element.toggleAttribute(directiveValue, value); + } break; } case "set": { @@ -232,6 +239,57 @@ return element; } + // src/cryptUtility.ts + var IV_SIZE = 12; + var ENCRYPTION_ALG = "AES-GCM"; + function encode(string) { + return new TextEncoder().encode(string); + } + function decode(array) { + return new TextDecoder("utf-8").decode(array); + } + async function encrypt(iv, key, message) { + const arrayBuffer = await window.crypto.subtle.encrypt( + { name: ENCRYPTION_ALG, iv }, + key, + encode(message) + ); + return new Uint8Array(arrayBuffer); + } + async function decrypt(iv, key, cyphertext) { + const arrayBuffer = await crypto.subtle.decrypt( + { name: ENCRYPTION_ALG, iv }, + key, + cyphertext + ); + return arrayBufferToString(arrayBuffer); + } + async function hash(encoded) { + return await crypto.subtle.digest("SHA-256", encoded); + } + function generateIV() { + return crypto.getRandomValues(new Uint8Array(IV_SIZE)); + } + async function importKey(passphrase, purpose) { + return await crypto.subtle.importKey( + "raw", + await hash(encode(passphrase)), + { name: ENCRYPTION_ALG }, + false, + [purpose] + ); + } + function arrayBufferToString(arrayBuffer) { + const uInt8Array = new Uint8Array(arrayBuffer); + return decode(uInt8Array); + } + function uInt8ToArray(uInt8Array) { + return Array.from(uInt8Array); + } + function arrayToUint8(array) { + return new Uint8Array(array); + } + // node_modules/udn-frontend/index.ts var UDNFrontend = class { ws; @@ -312,14 +370,15 @@ var serverAddress = restoreState("socket-address", ""); var isConnected = new State(false); var cannotConnect = createProxyState( - [serverAddress, isConnected], + [serverAddress, currentAddress, isConnected], () => serverAddress.value == "" || currentAddress.value == serverAddress.value && isConnected.value == true ); + var encryptionKey = restoreState("encryption-key", ""); + var isEncryptionUnavailable = window.crypto.subtle == void 0; var currentPrimaryChannel = new State(""); var primaryChannel = restoreState("primary-channel", ""); var newSecondaryChannelName = new State(""); var secondaryChannels = restoreListState("secondary-channels"); - var encryptionKey = restoreState("encryption-key", ""); var senderName = restoreState("sender-name", ""); var messageBody = restoreState("message", ""); var cannotSetChannel = createProxyState( @@ -338,9 +397,10 @@ function connect() { if (cannotConnect.value == true) return; currentAddress.value = serverAddress.value; + isConnected.value = false; UDN.connect(serverAddress.value); } - function sendMessage() { + async function sendMessage() { if (cannotSendMessage.value == true) return; const secondaryChannelNames = [ ...secondaryChannels.value.values() @@ -349,17 +409,22 @@ primaryChannel.value, ...secondaryChannelNames ]; + const encrypted = await encryptMessage(); + if (encrypted == void 0) return; const joinedChannelName = allChannelNames.join("/"); const messageObject = new Message( joinedChannelName, senderName.value, - messageBody.value, + encrypted, (/* @__PURE__ */ new Date()).toISOString() ); const messageString = JSON.stringify(messageObject); UDN.sendMessage(joinedChannelName, messageString); messageBody.value = ""; } + function clearMessageHistory() { + messages.clear(); + } function setChannel() { if (cannotSetChannel.value == true) return; if (currentPrimaryChannel != void 0) { @@ -378,6 +443,7 @@ } UDN.onconnect = () => { isConnected.value = true; + setChannel(); }; UDN.onmessage = (data) => { if (data.subscribed != void 0 && data.messageChannel != void 0) { @@ -390,12 +456,11 @@ isConnected.value = false; }; function handleSubscriptionConfirmation(channel, isSubscribed) { - console.log(channel, isSubscribed); if (isSubscribed == true) { currentPrimaryChannel.value = channel; } } - function handleMessage(body) { + async function handleMessage(body) { try { const object = JSON.parse(body); if (!object.channel || !object.sender || !object.body || !object.isoDate) @@ -403,13 +468,37 @@ const messageObject = new Message( object.channel, object.sender, - object.body, + await decryptMessage(object.body), object.isoDate ); messages.add(messageObject); } catch { } } + async function encryptMessage() { + if (encryptionKey.value == "") return messageBody.value; + if (!window.crypto.subtle) return messageBody.value; + const iv = generateIV(); + const key = await importKey(encryptionKey.value, "encrypt"); + const encryptedArray = await encrypt(iv, key, messageBody.value); + const encryptionData = { + iv: uInt8ToArray(iv), + encryptedArray: uInt8ToArray(encryptedArray) + }; + return btoa(JSON.stringify(encryptionData)); + } + async function decryptMessage(encryptionData) { + try { + const encrypionData = JSON.parse(atob(encryptionData)); + const iv = arrayToUint8(encrypionData.iv); + const encryptedArray = arrayToUint8(encrypionData.encryptedArray); + const key = await importKey(encryptionKey.value, "decrypt"); + return await decrypt(iv, key, encryptedArray); + } catch (error) { + console.error(error); + return encryptionData; + } + } // src/translations.ts var englishTranslations = { @@ -417,7 +506,7 @@ setInput: "Set", connectionStatus: "Connection status", connectedTo: createProxyState( - [currentAddress], + [], () => `Connected to ${currentAddress}` ), disconnected: "Disconnected", @@ -442,7 +531,10 @@ // messages messages: "Messages", composerPlaceholder: "Type a message...", - sendMessage: "Send" + sendMessage: "Send", + clearHistory: "Clear history", + encryptionUnavailableTitle: "Encryption is not available", + encryptionUnavailableMessage: "Encryption is not available on insecure contexts. Obtain this app via HTTPS or continue without encryption" }; var allTranslations = { en: englishTranslations @@ -482,7 +574,14 @@ // src/Tabs/messageTab.tsx function MessageTab() { - return /* @__PURE__ */ createElement("article", { id: "message-tab" }, /* @__PURE__ */ createElement("header", null, translation.messages), ThreadView(), /* @__PURE__ */ createElement("footer", null, MessageComposer())); + return /* @__PURE__ */ createElement("article", { id: "message-tab" }, /* @__PURE__ */ createElement("header", null, translation.messages, /* @__PURE__ */ createElement("span", null, /* @__PURE__ */ createElement( + "button", + { + "aria-label": translation.clearHistory, + "on:click": clearMessageHistory + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "delete_sweep") + ))), ThreadView(), /* @__PURE__ */ createElement("footer", null, MessageComposer())); } // src/Views/communicationSection.tsx @@ -593,9 +692,18 @@ () => shouldShowKey.value ? "text" : "password" ); function EncryptionSection() { - return /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("h2", null, translation.encryption), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "key"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.encryptionKey), /* @__PURE__ */ createElement( + return /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("h2", null, translation.encryption), /* @__PURE__ */ createElement( + "div", + { + class: "error tile margin-bottom", + "toggle:hidden": !isEncryptionUnavailable + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "warning"), + /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", null, translation.encryptionUnavailableTitle), /* @__PURE__ */ createElement("p", null, translation.encryptionUnavailableMessage)) + ), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "key"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.encryptionKey), /* @__PURE__ */ createElement( "input", { + "toggle:disabled": isEncryptionUnavailable, "bind:value": encryptionKey, "set:type": inputType, placeholder: translation.encryptionKeyPlaceholder diff --git a/dist/styles.css b/dist/styles.css index c38562c9..dc329713 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -932,6 +932,10 @@ main { overflow-x: scroll; } +.tile { + word-break: break-word; +} + /* scrolling */ main { scrollbar-width: none; diff --git a/package.json b/package.json index 3f355fd4..73f7340e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "bloatless-react": "^1.2.1", + "bloatless-react": "^1.2.3", "esbuild": "latest", "udn-frontend": "^1.0.2" }, diff --git a/src/Tabs/messageTab.tsx b/src/Tabs/messageTab.tsx index f9323dd4..d0960fa3 100644 --- a/src/Tabs/messageTab.tsx +++ b/src/Tabs/messageTab.tsx @@ -2,12 +2,23 @@ import * as React from "bloatless-react"; import { MessageComposer } from "../Views/messageComposer"; import { ThreadView } from "../Views/threadView"; +import { clearMessageHistory } from "../model"; import { translation } from "../translations"; export function MessageTab() { return (
-
{translation.messages}
+
+ {translation.messages} + + + +
{ThreadView()}
diff --git a/src/Views/encryptionSection.tsx b/src/Views/encryptionSection.tsx index 0929cb9d..8533bd9c 100644 --- a/src/Views/encryptionSection.tsx +++ b/src/Views/encryptionSection.tsx @@ -1,6 +1,7 @@ import * as React from "bloatless-react"; -import { encryptionKey } from "../model"; +import { encryptionKey, isEncryptionUnavailable } from "../model"; + import { translation } from "../translations"; const shouldShowKey = new React.State(false); @@ -12,11 +13,23 @@ export function EncryptionSection() { return (

{translation.encryption}

+
+ warning +
+ {translation.encryptionUnavailableTitle} +

{translation.encryptionUnavailableMessage}

+
+
+