diff --git a/bun.lockb b/bun.lockb index 416051a8..51310240 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 96bd64f7..27806c66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "bloatless-react": "^1.2.5", + "bloatless-react": "^1.2.6", "esbuild": "latest", "udn-frontend": "^1.0.7-beta1" }, diff --git a/src/Model/chatModel.ts b/src/Model/chatModel.ts new file mode 100644 index 00000000..2cf82cd7 --- /dev/null +++ b/src/Model/chatModel.ts @@ -0,0 +1,160 @@ +import * as React from "bloatless-react"; + +import { UDN, isConnected } from "./model"; +import { decryptString, encryptString } from "../cryptUtility"; + +import { senderName } from "./viewModel"; +import { storageKeys } from "../utility"; + +// TYPES +// message +export interface Message { + channel: string; + sender: string; + body: string; + isoDate: string; +} + +export class Message { + constructor( + public channel: string, + public sender: string, + public body: string, + public isoDate: string + ) {} +} + +// chat +export class Chat { + id: string; + + primaryChannel: React.State; + secondaryChannels: React.ListState; + + encryptionKey: React.State; + messages: React.ListState; + + composingMessage: React.State; + cannotSendMessage: React.State; + + newSecondaryChannelName: React.State; + cannotAddSecondaryChannel: React.State; + + // init + constructor(id: string = React.UUID()) { + this.primaryChannel = React.restoreState( + storageKeys.primaryChannel(id), + "" + ); + this.secondaryChannels = React.restoreListState( + storageKeys.secondaryChannels(id) + ); + + this.encryptionKey = React.restoreState(storageKeys.encyptionKey(id), ""); + this.messages = React.restoreListState(storageKeys.messages(id)); + + this.composingMessage = React.restoreState( + storageKeys.composingMessage(id), + "" + ); + this.newSecondaryChannelName = new React.State(""); + + this.cannotSendMessage = React.createProxyState( + [this.primaryChannel, this.composingMessage, isConnected], + () => + this.primaryChannel.value == "" || + this.composingMessage.value == "" || + isConnected.value == false + ); + this.cannotAddSecondaryChannel = React.createProxyState( + [this.newSecondaryChannelName], + () => this.newSecondaryChannelName.value == "" + ); + } + + // messages + async sendMessage(): Promise { + if (this.cannotSendMessage.value == true) return; + + // get channels + const secondaryChannelNames: string[] = [ + ...this.secondaryChannels.value.values(), + ].map((channel) => channel); + const allChannelNames: string[] = [ + this.primaryChannel.value, + ...secondaryChannelNames, + ]; + + // encrypt + const encrypted = + (await encryptString( + this.composingMessage.value, + this.encryptionKey.value + )) || this.composingMessage.value; + if (encrypted == undefined) return; + + // create object + const joinedChannelName = allChannelNames.join("/"); + const messageObject = new Message( + joinedChannelName, + senderName.value, + encrypted, + new Date().toISOString() + ); + const messageString = JSON.stringify(messageObject); + + // send + UDN.sendMessage(joinedChannelName, messageString); + + // clear + this.composingMessage.value = ""; + } + + clearMessages(): void { + this.messages.clear(); + } + + deleteMessage(message: Message): void { + this.messages.remove(message); + } + + async decryptReceivedMessage(message: Message): Promise { + message.body = await decryptString(message.body, this.encryptionKey.value); + this.messages.callSubscriptions(); + } + + // channel + setChannel(): void {} + + addSecondaryChannel(): void { + if (this.cannotAddSecondaryChannel.value == true) return; + + this.secondaryChannels.add(this.newSecondaryChannelName.value); + this.newSecondaryChannelName.value = ""; + } + + removeSecondaryChannel(channel: string): void { + this.secondaryChannels.remove(channel); + } +} + +// INIT +const chatIds = React.restoreListState("chat-ids"); +const chatArray = [...chatIds.value.values()].map((id) => new Chat(id)); +export const chats = new React.ListState(chatArray); + +// METHODS +export function createChatWithName(name: string): void { + const newChat = new Chat(); + newChat.primaryChannel.value = name; + chatIds.add(newChat.id); +} + +export function removeChat(chat: Chat): void { + Object.values(storageKeys).forEach((cb) => { + localStorage.removeItem(cb(chat.id)); + }); + + chats.remove(chat); + chatIds.remove(chat.id); +} diff --git a/src/Model/model.ts b/src/Model/model.ts new file mode 100644 index 00000000..34525a6f --- /dev/null +++ b/src/Model/model.ts @@ -0,0 +1,65 @@ +import * as React from "bloatless-react"; + +import { + arrayToUint8, + decrypt, + encrypt, + generateIV, + importKey, + uInt8ToArray, +} from "../cryptUtility"; + +import UDNFrontend from "udn-frontend"; +import { serverAddress } from "./viewModel"; +import { translation } from "../translations"; + +export const UDN = new UDNFrontend(); + +// CONNECTION +// state +export const isConnected = new React.State(false); +export const currentAddress = new React.State(""); + +export const cannotDisonnect = React.createProxyState( + [isConnected], + () => isConnected.value == false +); + +export const cannotConnect = React.createProxyState( + [serverAddress, currentAddress, isConnected], + () => + serverAddress.value == "" || + (currentAddress.value == serverAddress.value && isConnected.value == true) +); + +// methods +export function connect(): void { + if (cannotConnect.value == true) return; + currentAddress.value = serverAddress.value; + isConnected.value = false; + UDN.connect(serverAddress.value); +} + +export function disconnect(): void { + if (cannotDisonnect.value == true) return; + UDN.disconnect(); +} + +// LISTENERS +UDN.onconnect = () => { + isConnected.value = true; +}; + + +UDN.onmessage = (data) => { + +}; + +UDN.ondisconnect = () => { + isConnected.value = false; +}; + +// INIT +if (serverAddress.value != "") { + connect(); +} diff --git a/src/Model/viewModel.ts b/src/Model/viewModel.ts new file mode 100644 index 00000000..085771b9 --- /dev/null +++ b/src/Model/viewModel.ts @@ -0,0 +1,21 @@ +import * as React from "bloatless-react"; + +import { translation } from "../translations"; + +// connection +export const currentAddress = new React.State(""); + +export const serverAddress = React.restoreState("socket-address", ""); +export const isConnected = new React.State(false); +export const connectionMessage = React.createProxyState([serverAddress], () => + translation.connectedTo(serverAddress.value) +); + +// misc +export const isEncryptionUnavailable = window.crypto.subtle == undefined; +export const senderName = React.restoreState("sender-name", ""); + +// chat +export const newChatPrimaryChannelName = new React.State(""); +export const newChatSecondaryChannelName = new React.State(""); +export const newChatSecondaryChannelNames = new React.ListState(); \ No newline at end of file diff --git a/src/cryptUtility.ts b/src/cryptUtility.ts index 58a904c6..a0ae34f4 100644 --- a/src/cryptUtility.ts +++ b/src/cryptUtility.ts @@ -1,6 +1,40 @@ const IV_SIZE = 12; const ENCRYPTION_ALG = "AES-GCM"; +// minimal +export async function encryptString( + plaintext: string, + passphrase: string +): Promise { + if (!window.crypto.subtle) return false; + + const iv = generateIV(); + const key = await importKey(passphrase, "encrypt"); + const encryptedArray = await encrypt(iv, key, plaintext); + + const encryptionData = { + iv: uInt8ToArray(iv), + encryptedArray: uInt8ToArray(encryptedArray), + }; + return btoa(JSON.stringify(encryptionData)); +} + +export async function decryptString( + cyphertext: string, + passphrase: string +): Promise { + try { + const encrypionData = JSON.parse(atob(cyphertext)); + const iv = arrayToUint8(encrypionData.iv); + const encryptedArray = arrayToUint8(encrypionData.encryptedArray); + + const key = await importKey(passphrase, "decrypt"); + return await decrypt(iv, key, encryptedArray); + } catch { + return cyphertext; + } +} + // process export function encode(string: string): Uint8Array { return new TextEncoder().encode(string); diff --git a/src/model.ts b/src/model.ts deleted file mode 100644 index a77b0a76..00000000 --- a/src/model.ts +++ /dev/null @@ -1,333 +0,0 @@ -import * as React from "bloatless-react"; - -import { - arrayToUint8, - decrypt, - encrypt, - generateIV, - importKey, - uInt8ToArray, -} from "./cryptUtility"; - -import UDNFrontend from "udn-frontend"; -import { translation } from "./translations"; - -const UDN = new UDNFrontend(); - -// TYPE -export class Channel implements React.Identifiable { - id = React.UUID(); - constructor(public channelName: string) {} -} - -export interface Message extends React.Identifiable { - channel: string; - sender: string; - body: string; - isoDate: string; -} - -export class Message implements React.Identifiable { - id = React.UUID(); - - constructor( - public channel: string, - public sender: string, - public body: string, - public isoDate: string - ) {} -} - -// STATE -// connection -export const currentAddress = new React.State(""); -export const serverAddress = React.restoreState("socket-address", ""); -export const isConnected = new React.State(false); -export const connectionMessage = React.createProxyState([serverAddress], () => - translation.connectedTo(serverAddress.value) -); - -export const cannotDisonnect = React.createProxyState( - [isConnected], - () => isConnected.value == false -); - -export const cannotConnect = React.createProxyState( - [serverAddress, currentAddress, isConnected], - () => - serverAddress.value == "" || - (currentAddress.value == serverAddress.value && isConnected.value == true) -); - -// mailbox -export const mailboxId = React.restoreState("mailbox-id", ""); -export const isMailboxConnected = new React.State(false); - -export const cannotRequestMailbox = React.createProxyState( - [isConnected, isMailboxConnected], - () => isConnected.value == false || isMailboxConnected.value == true -); - -export const cannotDeleteMailbox = React.createProxyState( - [isConnected, mailboxId, isMailboxConnected], - () => - isConnected.value == false || - mailboxId.value == "" || - isMailboxConnected.value == false -); - -// encryption -export const encryptionKey = React.restoreState("encryption-key", ""); -export const isEncryptionUnavailable = window.crypto.subtle == undefined; - -// channel -export const currentPrimaryChannel = new React.State(""); -export const primaryChannel = React.restoreState("primary-channel", ""); - -export const newSecondaryChannelName = new React.State(""); -export const secondaryChannels = - React.restoreListState("secondary-channels"); - -export const cannotSetChannel = React.createProxyState( - [primaryChannel, currentPrimaryChannel, isConnected], - () => primaryChannel.value == "" || isConnected.value == false -); - -export const cannotLeaveChannel = React.createProxyState( - [currentPrimaryChannel, isConnected], - () => currentPrimaryChannel.value == "" || isConnected.value == false -); - -export const cannotAddSecondaryChannel = React.createProxyState( - [newSecondaryChannelName], - () => newSecondaryChannelName.value == "" -); - -// sending -export const senderName = React.restoreState("sender-name", ""); - -export const messageBody = React.restoreState("message", ""); - -export const cannotSendMessage = React.createProxyState( - [currentPrimaryChannel, messageBody, senderName, isConnected], - () => - isConnected.value == false || - currentPrimaryChannel.value == "" || - messageBody.value == "" || - senderName.value == "" -); - -// receiving -export const messages = React.restoreListState("messages"); - -// METHODS -// connection -export function connect(): void { - if (cannotConnect.value == true) return; - currentAddress.value = serverAddress.value; - isConnected.value = false; - UDN.connect(serverAddress.value); -} - -export function disconnect(): void { - if (cannotDisonnect.value == true) return; - UDN.disconnect(); -} - -// mailbox -export function requestMailbox(): void { - UDN.requestMailbox(); -} - -export function deleteMailbox(): void { - UDN.deleteMailbox(mailboxId.value); -} - -export function updateMailbox(): void { - if (!isMailboxConnected || mailboxId.value == "") return; - - deleteMailbox(); - requestMailbox(); -} - -// messages -export async function sendMessage(): Promise { - if (cannotSendMessage.value == true) return; - - // get channels - const secondaryChannelNames: string[] = [ - ...secondaryChannels.value.values(), - ].map((channel) => channel.channelName); - const allChannelNames: string[] = [ - primaryChannel.value, - ...secondaryChannelNames, - ]; - - // encrypt - const encrypted = await encryptMessage(); - if (encrypted == undefined) return; - - // create object - const joinedChannelName = allChannelNames.join("/"); - const messageObject = new Message( - joinedChannelName, - senderName.value, - encrypted, - new Date().toISOString() - ); - const messageString = JSON.stringify(messageObject); - - // send - UDN.sendMessage(joinedChannelName, messageString); - - // clear - messageBody.value = ""; -} - -export function clearMessageHistory(): void { - messages.clear(); -} - -export function deleteMessage(message: Message): void { - messages.remove(message); -} - -// channels -export function setChannel(shouldUpdateMailbox: boolean = true): void { - if (cannotSetChannel.value == true) return; - - if (currentPrimaryChannel != undefined) { - UDN.unsubscribe(currentPrimaryChannel.value); - } - UDN.subscribe(primaryChannel.value); - - if (shouldUpdateMailbox) { - updateMailbox(); - } -} - -export function leaveChannel(): void { - if (cannotLeaveChannel.value == true) return; - - UDN.unsubscribe(currentPrimaryChannel.value); -} - -export function addSecondaryChannel(): void { - if (cannotAddSecondaryChannel.value == true) return; - - const channelObject = new Channel(newSecondaryChannelName.value); - secondaryChannels.add(channelObject); - newSecondaryChannelName.value = ""; -} - -export function removeSecondaryChannel(channel: Channel): void { - secondaryChannels.remove(channel); -} - -// encryption -export async function decryptReceivedMessage(message: Message): Promise { - message.body = await decryptMessage(message.body); - messages.callSubscriptions(); -} - -// LISTENERS -UDN.onconnect = () => { - isConnected.value = true; - setChannel(false); - - if (mailboxId.value != "") { - UDN.connectMailbox(mailboxId.value); - } -}; - -UDN.onmailboxcreate = (id) => { - mailboxId.value = id; - UDN.connectMailbox(id); -}; - -UDN.onmailboxconnect = () => { - console.log("connected") - isMailboxConnected.value = true; -}; - -UDN.onmailboxdelete = () => { - isMailboxConnected.value = false; - mailboxId.value = ""; - setChannel(); -}; - -UDN.onmessage = (data) => { - if (data.subscribed != undefined && data.messageChannel != undefined) { - return handleSubscriptionConfirmation(data.messageChannel, data.subscribed); - } else if (data.messageBody) { - handleMessage(data.messageBody); - } -}; - -UDN.ondisconnect = () => { - isConnected.value = false; - isMailboxConnected.value = false; -}; - -// UTILITY -function handleSubscriptionConfirmation( - channel: string, - isSubscribed: boolean -): void { - if (isSubscribed == true) { - currentPrimaryChannel.value = channel; - } else { - currentPrimaryChannel.value = ""; - } -} - -async function handleMessage(body: string): Promise { - try { - const object: Message = JSON.parse(body); - if (!object.channel || !object.sender || !object.body || !object.isoDate) - return; - - const messageObject = new Message( - object.channel, - object.sender, - await decryptMessage(object.body), - object.isoDate - ); - messages.add(messageObject); - } catch {} -} - -async function encryptMessage(): Promise { - 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 { - 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; - } -} - -// INIT -if (serverAddress.value != "") { - connect(); -} diff --git a/src/utility.ts b/src/utility.ts new file mode 100644 index 00000000..95152fd0 --- /dev/null +++ b/src/utility.ts @@ -0,0 +1,21 @@ +export const storageKeys = { + primaryChannel(id: string): string { + return id + "primary-channel"; + }, + + secondaryChannels(id: string): string { + return id + "secondary-channels"; + }, + + encyptionKey(id: string): string { + return id + "encryption-key"; + }, + + messages(id: string): string { + return id + "messages"; + }, + + composingMessage(id: string): string { + return id + "composing-message"; + }, +};