add encryption

This commit is contained in:
marlon-erler
2024-07-08 00:39:22 +02:00
parent cb2b407d30
commit 38cfd6160e
10 changed files with 303 additions and 31 deletions
BIN
View File
Binary file not shown.
+119 -11
View File
@@ -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": {
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
+4
View File
@@ -932,6 +932,10 @@ main {
overflow-x: scroll;
}
.tile {
word-break: break-word;
}
/* scrolling */
main {
scrollbar-width: none;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"dependencies": {
"bloatless-react": "^1.2.1",
"bloatless-react": "^1.2.3",
"esbuild": "latest",
"udn-frontend": "^1.0.2"
},
+12 -1
View File
@@ -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 (
<article id="message-tab">
<header>{translation.messages}</header>
<header>
{translation.messages}
<span>
<button
aria-label={translation.clearHistory}
on:click={clearMessageHistory}
>
<span class="icon">delete_sweep</span>
</button>
</span>
</header>
{ThreadView()}
<footer>{MessageComposer()}</footer>
</article>
+14 -1
View File
@@ -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 (
<div>
<h2>{translation.encryption}</h2>
<div
class="error tile margin-bottom"
toggle:hidden={!isEncryptionUnavailable}
>
<span class="icon">warning</span>
<div>
<b>{translation.encryptionUnavailableTitle}</b>
<p>{translation.encryptionUnavailableMessage}</p>
</div>
</div>
<label class="tile">
<span class="icon">key</span>
<div>
<span>{translation.encryptionKey}</span>
<input
toggle:disabled={isEncryptionUnavailable}
bind:value={encryptionKey}
set:type={inputType}
placeholder={translation.encryptionKeyPlaceholder}
+81
View File
@@ -0,0 +1,81 @@
const IV_SIZE = 12;
const ENCRYPTION_ALG = "AES-GCM";
// process
export function encode(string: string): Uint8Array {
return new TextEncoder().encode(string);
}
export function decode(array: Uint8Array): string {
return new TextDecoder("utf-8").decode(array);
}
export async function encrypt(
iv: Uint8Array,
key: CryptoKey,
message: string
): Promise<Uint8Array> {
const arrayBuffer = await window.crypto.subtle.encrypt(
{ name: ENCRYPTION_ALG, iv },
key,
encode(message)
);
return new Uint8Array(arrayBuffer);
}
export async function decrypt(
iv: Uint8Array,
key: CryptoKey,
cyphertext: Uint8Array
): Promise<string> {
const arrayBuffer = await crypto.subtle.decrypt(
{ name: ENCRYPTION_ALG, iv },
key,
cyphertext
);
return arrayBufferToString(arrayBuffer);
}
export async function hash(encoded: Uint8Array): Promise<ArrayBuffer> {
return await crypto.subtle.digest("SHA-256", encoded);
}
// iv
export function generateIV(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(IV_SIZE));
}
export function splitMergedString(mergedString: string): [Uint8Array, string] {
const iv = mergedString.slice(0, IV_SIZE);
const encryptedString = mergedString.slice(IV_SIZE);
return [JSON.parse(iv), encryptedString];
}
// key
export async function importKey(
passphrase: string,
purpose: "encrypt" | "decrypt"
): Promise<CryptoKey> {
return await crypto.subtle.importKey(
"raw",
await hash(encode(passphrase)),
{ name: ENCRYPTION_ALG },
false,
[purpose]
);
}
// conversion
export function arrayBufferToString(arrayBuffer: ArrayBuffer): string {
const uInt8Array = new Uint8Array(arrayBuffer);
return decode(uInt8Array);
}
export function uInt8ToArray(uInt8Array: Uint8Array): number[] {
return Array.from(uInt8Array);
}
export function arrayToUint8(array: number[]): Uint8Array {
return new Uint8Array(array);
}
-3
View File
@@ -1,10 +1,7 @@
import "./styles.css";
import * as React from "bloatless-react";
import { MessageTab } from "./Tabs/messageTab";
import { SettingsTab } from "./Tabs/settingsTab";
import { translation } from "./translations";
document.querySelector("main")!.append(SettingsTab(), MessageTab());
document.querySelector("main")!.classList.add("split");
+61 -7
View File
@@ -1,6 +1,18 @@
import * as React from "bloatless-react";
import {
arrayToUint8,
decode,
decrypt,
encrypt,
generateIV,
importKey,
splitMergedString,
uInt8ToArray,
} from "./cryptUtility";
import UDNFrontend from "udn-frontend";
import { translation } from "./translations";
const UDN = new UDNFrontend();
@@ -35,19 +47,22 @@ export const serverAddress = React.restoreState("socket-address", "");
export const isConnected = new React.State(false);
export const cannotConnect = React.createProxyState(
[serverAddress, isConnected],
[serverAddress, currentAddress, isConnected],
() =>
serverAddress.value == "" ||
(currentAddress.value == serverAddress.value && isConnected.value == true)
);
// encryption
export const encryptionKey = React.restoreState("encryption-key", "");
export const isEncryptionUnavailable = window.crypto.subtle == undefined;
// sending
export const currentPrimaryChannel = new React.State("");
export const primaryChannel = React.restoreState("primary-channel", "");
export const newSecondaryChannelName = new React.State("");
export const secondaryChannels =
React.restoreListState<Channel>("secondary-channels");
export const encryptionKey = React.restoreState("encryption-key", "");
export const senderName = React.restoreState("sender-name", "");
export const messageBody = React.restoreState("message", "");
@@ -79,10 +94,11 @@ export const messages = React.restoreListState<Message>("messages");
export function connect() {
if (cannotConnect.value == true) return;
currentAddress.value = serverAddress.value;
isConnected.value = false;
UDN.connect(serverAddress.value);
}
export function sendMessage() {
export async function sendMessage(): Promise<void> {
if (cannotSendMessage.value == true) return;
// get channels
@@ -94,12 +110,16 @@ export function sendMessage() {
...secondaryChannelNames,
];
// encrypt
const encrypted = await encryptMessage();
if (encrypted == undefined) return;
// create object
const joinedChannelName = allChannelNames.join("/");
const messageObject = new Message(
joinedChannelName,
senderName.value,
messageBody.value,
encrypted,
new Date().toISOString()
);
const messageString = JSON.stringify(messageObject);
@@ -111,6 +131,10 @@ export function sendMessage() {
messageBody.value = "";
}
export function clearMessageHistory() {
messages.clear();
}
export function setChannel(): void {
if (cannotSetChannel.value == true) return;
@@ -135,6 +159,7 @@ export function removeSecondaryChannel(channel: Channel): void {
// LISTENERS
UDN.onconnect = () => {
isConnected.value = true;
setChannel();
};
UDN.onmessage = (data) => {
@@ -154,13 +179,12 @@ function handleSubscriptionConfirmation(
channel: string,
isSubscribed: boolean
): void {
console.log(channel, isSubscribed);
if (isSubscribed == true) {
currentPrimaryChannel.value = channel;
}
}
function handleMessage(body: string): void {
async function handleMessage(body: string): Promise<void> {
try {
const object: Message = JSON.parse(body);
if (!object.channel || !object.sender || !object.body || !object.isoDate)
@@ -169,9 +193,39 @@ function handleMessage(body: string): void {
const messageObject = new Message(
object.channel,
object.sender,
object.body,
await decryptMessage(object.body),
object.isoDate
);
messages.add(messageObject);
} catch {}
}
async function encryptMessage(): Promise<string> {
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: string): Promise<string> {
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;
}
}
+7 -3
View File
@@ -1,13 +1,13 @@
import * as React from "bloatless-react";
import { currentAddress, primaryChannel } from "./model";
import { currentAddress } from "./model";
const englishTranslations = {
// general
setInput: "Set",
connectionStatus: "Connection status",
connectedTo: React.createProxyState(
[currentAddress],
[],
() => `Connected to ${currentAddress}`
),
disconnected: "Disconnected",
@@ -34,7 +34,11 @@ const englishTranslations = {
// 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",
};
const allTranslations = {