pgp encryption options for dms
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
|
"openpgp": "^6.3.0",
|
||||||
"spacetimedb": "^2.1.0",
|
"spacetimedb": "^2.1.0",
|
||||||
"svelte": "^5.55.1",
|
"svelte": "^5.55.1",
|
||||||
"svelte-check": "^4.4.6"
|
"svelte-check": "^4.4.6"
|
||||||
|
|||||||
Generated
+9
@@ -20,6 +20,9 @@ importers:
|
|||||||
oidc-client-ts:
|
oidc-client-ts:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
openpgp:
|
||||||
|
specifier: ^6.3.0
|
||||||
|
version: 6.3.0
|
||||||
spacetimedb:
|
spacetimedb:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0(react@18.3.1)(svelte@5.55.1)
|
version: 2.1.0(react@18.3.1)(svelte@5.55.1)
|
||||||
@@ -1779,6 +1782,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
|
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
openpgp@6.3.0:
|
||||||
|
resolution: {integrity: sha512-pLzCU8IgyKXPSO11eeharQkQ4GzOKNWhXq79pQarIRZEMt1/ssyr+MIuWBv1mNoenJLg04gvPx+fi4gcKZ4bag==}
|
||||||
|
engines: {node: '>= 18.0.0'}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3672,6 +3679,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
jwt-decode: 4.0.0
|
jwt-decode: 4.0.0
|
||||||
|
|
||||||
|
openpgp@6.3.0: {}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub fn init(ctx: &ReducerContext) {
|
|||||||
banner_id: None,
|
banner_id: None,
|
||||||
biography: Some("I am the Zep system assistant.".to_string()),
|
biography: Some("I am the Zep system assistant.".to_string()),
|
||||||
status: Some("Online".to_string()),
|
status: Some("Online".to_string()),
|
||||||
|
public_key: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ pub fn on_connect(ctx: &ReducerContext) {
|
|||||||
banner_id: None,
|
banner_id: None,
|
||||||
biography: None,
|
biography: None,
|
||||||
status: None,
|
status: None,
|
||||||
|
public_key: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Minimal auto-join
|
// Minimal auto-join
|
||||||
|
|||||||
@@ -383,7 +383,18 @@ pub fn set_banner(ctx: &ReducerContext, banner_id: Option<u64>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::reducer]
|
#[spacetimedb::reducer]
|
||||||
pub fn set_biography(ctx: &ReducerContext, biography: Option<String>) {
|
pub fn update_public_key(ctx: &ReducerContext, public_key: Option<String>) {
|
||||||
|
if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) {
|
||||||
|
user.public_key = public_key;
|
||||||
|
ctx.db.user().identity().update(user);
|
||||||
|
} else {
|
||||||
|
panic!("User not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn set_biography(
|
||||||
|
ctx: &ReducerContext, biography: Option<String>) {
|
||||||
let mut user = ctx
|
let mut user = ctx
|
||||||
.db
|
.db
|
||||||
.user()
|
.user()
|
||||||
@@ -849,13 +860,14 @@ pub fn create_thread_with_message(
|
|||||||
parent_message_id: u64,
|
parent_message_id: u64,
|
||||||
text: String,
|
text: String,
|
||||||
image_ids: Vec<u64>,
|
image_ids: Vec<u64>,
|
||||||
|
is_encrypted: bool,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if text.trim().is_empty() && image_ids.is_empty() {
|
if text.trim().is_empty() && image_ids.is_empty() {
|
||||||
panic!("Messages must not be empty");
|
panic!("Messages must not be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !text.is_empty() {
|
if !text.is_empty() && !is_encrypted {
|
||||||
validate_message_length(&ctx.db, &text).expect("Message too long");
|
validate_message_length(&ctx.db, &text).expect("Message too long");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,6 +911,7 @@ pub fn create_thread_with_message(
|
|||||||
thread_name: None,
|
thread_name: None,
|
||||||
thread_reply_count: 0,
|
thread_reply_count: 0,
|
||||||
edited: false,
|
edited: false,
|
||||||
|
is_encrypted,
|
||||||
});
|
});
|
||||||
|
|
||||||
let seq_id = get_next_seq_id(&ctx.db, channel_id);
|
let seq_id = get_next_seq_id(&ctx.db, channel_id);
|
||||||
@@ -926,13 +939,15 @@ pub fn create_thread_with_message(
|
|||||||
thread_id: Some(t.id),
|
thread_id: Some(t.id),
|
||||||
sent: ctx.timestamp,
|
sent: ctx.timestamp,
|
||||||
seq_id,
|
seq_id,
|
||||||
reactions: Vec::new(),
|
reactions: msg.reactions,
|
||||||
image_ids,
|
image_ids: msg.image_ids,
|
||||||
thread_name: None,
|
thread_name: None,
|
||||||
thread_reply_count: 0,
|
thread_reply_count: 0,
|
||||||
edited: false,
|
edited: false,
|
||||||
|
is_encrypted,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Update parent message metadata
|
// Update parent message metadata
|
||||||
if let Some(mut parent_msg) = ctx.db.message().id().find(parent_message_id) {
|
if let Some(mut parent_msg) = ctx.db.message().id().find(parent_message_id) {
|
||||||
parent_msg.thread_name = Some(name.clone());
|
parent_msg.thread_name = Some(name.clone());
|
||||||
@@ -969,13 +984,14 @@ pub fn send_message(
|
|||||||
channel_id: u64,
|
channel_id: u64,
|
||||||
thread_id: Option<u64>,
|
thread_id: Option<u64>,
|
||||||
image_ids: Vec<u64>,
|
image_ids: Vec<u64>,
|
||||||
|
is_encrypted: bool,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if text.trim().is_empty() && image_ids.is_empty() {
|
if text.trim().is_empty() && image_ids.is_empty() {
|
||||||
panic!("Messages must not be empty");
|
panic!("Messages must not be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !text.is_empty() {
|
if !text.is_empty() && !is_encrypted {
|
||||||
validate_message_length(&ctx.db, &text).expect("Message too long");
|
validate_message_length(&ctx.db, &text).expect("Message too long");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,6 +1018,7 @@ pub fn send_message(
|
|||||||
thread_name: None,
|
thread_name: None,
|
||||||
thread_reply_count: 0,
|
thread_reply_count: 0,
|
||||||
edited: false,
|
edited: false,
|
||||||
|
is_encrypted,
|
||||||
});
|
});
|
||||||
|
|
||||||
let seq_id = get_next_seq_id(&ctx.db, channel_id);
|
let seq_id = get_next_seq_id(&ctx.db, channel_id);
|
||||||
@@ -1029,13 +1046,15 @@ pub fn send_message(
|
|||||||
thread_id,
|
thread_id,
|
||||||
sent: ctx.timestamp,
|
sent: ctx.timestamp,
|
||||||
seq_id,
|
seq_id,
|
||||||
reactions: Vec::new(),
|
reactions: msg.reactions,
|
||||||
image_ids,
|
image_ids: msg.image_ids,
|
||||||
thread_name: None,
|
thread_name: None,
|
||||||
thread_reply_count: 0,
|
thread_reply_count: 0,
|
||||||
edited: false,
|
edited: false,
|
||||||
|
is_encrypted,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// If it's a thread message, update parent message metadata
|
// If it's a thread message, update parent message metadata
|
||||||
if let Some(tid) = thread_id {
|
if let Some(tid) = thread_id {
|
||||||
if let Some(thread) = ctx.db.thread().id().find(tid) {
|
if let Some(thread) = ctx.db.thread().id().find(tid) {
|
||||||
@@ -1121,6 +1140,7 @@ pub fn bootstrap_sequences(ctx: &ReducerContext) {
|
|||||||
thread_name: msg.thread_name.clone(),
|
thread_name: msg.thread_name.clone(),
|
||||||
thread_reply_count: msg.thread_reply_count,
|
thread_reply_count: msg.thread_reply_count,
|
||||||
edited: msg.edited,
|
edited: msg.edited,
|
||||||
|
is_encrypted: msg.is_encrypted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct User {
|
|||||||
pub banner_id: Option<u64>,
|
pub banner_id: Option<u64>,
|
||||||
pub biography: Option<String>,
|
pub biography: Option<String>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
pub public_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(spacetimedb::SpacetimeType, Clone, Debug)]
|
#[derive(spacetimedb::SpacetimeType, Clone, Debug)]
|
||||||
@@ -210,6 +211,7 @@ pub struct Message {
|
|||||||
pub thread_name: Option<String>,
|
pub thread_name: Option<String>,
|
||||||
pub thread_reply_count: u32,
|
pub thread_reply_count: u32,
|
||||||
pub edited: bool,
|
pub edited: bool,
|
||||||
|
pub is_encrypted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = custom_emoji, public)]
|
#[spacetimedb::table(accessor = custom_emoji, public)]
|
||||||
@@ -262,6 +264,7 @@ pub struct RecentMessage {
|
|||||||
pub thread_name: Option<String>,
|
pub thread_name: Option<String>,
|
||||||
pub thread_reply_count: u32,
|
pub thread_reply_count: u32,
|
||||||
pub edited: bool,
|
pub edited: bool,
|
||||||
|
pub is_encrypted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = system_configuration, public)]
|
#[spacetimedb::table(accessor = system_configuration, public)]
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ pub fn internal_send_message(db: &Local, sender: Identity, channel_id: u64, text
|
|||||||
thread_name: None,
|
thread_name: None,
|
||||||
thread_reply_count: 0,
|
thread_reply_count: 0,
|
||||||
edited: false,
|
edited: false,
|
||||||
|
is_encrypted: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let seq_id = get_next_seq_id(db, channel_id);
|
let seq_id = get_next_seq_id(db, channel_id);
|
||||||
@@ -408,6 +409,7 @@ pub fn internal_send_message(db: &Local, sender: Identity, channel_id: u64, text
|
|||||||
thread_reply_count: msg.thread_reply_count,
|
thread_reply_count: msg.thread_reply_count,
|
||||||
edited: msg.edited,
|
edited: msg.edited,
|
||||||
server_id: 0, // DMs have server_id 0
|
server_id: 0, // DMs have server_id 0
|
||||||
|
is_encrypted: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let limit = get_recent_message_limit(db);
|
let limit = get_recent_message_limit(db);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct VisibleMessageRow {
|
|||||||
pub thread_name: Option<String>,
|
pub thread_name: Option<String>,
|
||||||
pub thread_reply_count: u32,
|
pub thread_reply_count: u32,
|
||||||
pub edited: bool,
|
pub edited: bool,
|
||||||
|
pub is_encrypted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(spacetimedb::SpacetimeType)]
|
#[derive(spacetimedb::SpacetimeType)]
|
||||||
@@ -282,6 +283,7 @@ pub fn visible_scrollback_messages(ctx: &ViewContext) -> Vec<VisibleMessageRow>
|
|||||||
thread_name: msg.thread_name.clone(),
|
thread_name: msg.thread_name.clone(),
|
||||||
thread_reply_count: msg.thread_reply_count,
|
thread_reply_count: msg.thread_reply_count,
|
||||||
edited: msg.edited,
|
edited: msg.edited,
|
||||||
|
is_encrypted: msg.is_encrypted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -612,6 +612,21 @@ body {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.encryption-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background-color: rgba(88, 101, 242, 0.1);
|
||||||
|
border: 1px solid rgba(88, 101, 242, 0.3);
|
||||||
|
color: var(--brand);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -148,8 +148,14 @@
|
|||||||
{@const otherIdentity = dm.sender.toHexString() === myIdHex ? dm.recipient : dm.sender}
|
{@const otherIdentity = dm.sender.toHexString() === myIdHex ? dm.recipient : dm.sender}
|
||||||
{@const recipient = chat.users.find(u => u.identity.toHexString() === otherIdentity.toHexString())}
|
{@const recipient = chat.users.find(u => u.identity.toHexString() === otherIdentity.toHexString())}
|
||||||
{#if recipient}
|
{#if recipient}
|
||||||
<Avatar user={recipient} size={24} />
|
<Avatar user={recipient} size="tiny" />
|
||||||
<h2 style="margin: 0; font-size: 1rem;">{recipient.name || "Unknown User"}</h2>
|
<h2 style="margin: 0; font-size: 1rem;">{recipient.name || "Unknown User"}</h2>
|
||||||
|
{#if chat.getRecipientPublicKey(otherIdentity)}
|
||||||
|
<div class="encryption-indicator" title="End-to-end encrypted">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
<span>Encryption Available</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { ChatService } from "../services/chat.svelte";
|
import type { ChatService } from "../services/chat.svelte";
|
||||||
import type * as Types from "../../module_bindings/types";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -415,6 +415,17 @@
|
|||||||
}
|
}
|
||||||
return "Select a conversation";
|
return "Select a conversation";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showEncryptionToggle = $derived.by(() => {
|
||||||
|
if (chat.activeServer || !activeChannelId || !chat.isEncryptionReady) return false;
|
||||||
|
const dm = chat.activeDms.find(d => d.channelId === activeChannelId);
|
||||||
|
if (!dm) return false;
|
||||||
|
const myIdHex = chat.identity?.toHexString();
|
||||||
|
const otherIdentity = dm.sender.toHexString() === myIdHex ? dm.recipient : dm.sender;
|
||||||
|
return !!chat.getRecipientPublicKey(otherIdentity);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEncrypted = $derived(activeChannelId ? chat.isEncryptionEnabledForChannel(activeChannelId) : false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -496,6 +507,17 @@
|
|||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
rows="1"
|
rows="1"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
|
{#if showEncryptionToggle}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="encryption-toggle-btn {isEncrypted ? 'active' : ''}"
|
||||||
|
onclick={() => activeChannelId && chat.toggleEncryptionForChannel(activeChannelId)}
|
||||||
|
title={isEncrypted ? "End-to-end encryption is ON" : "Turn on end-to-end encryption"}
|
||||||
|
>
|
||||||
|
<i class="fas {isEncrypted ? 'fa-lock' : 'fa-lock-open'}"></i>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -522,6 +544,27 @@
|
|||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.encryption-toggle-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--interactive-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 0 10px 16px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encryption-toggle-btn:hover {
|
||||||
|
color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encryption-toggle-btn.active {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
.message-input-inner {
|
.message-input-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -133,6 +133,25 @@
|
|||||||
horizontal: 'left' | 'right'
|
horizontal: 'left' | 'right'
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// E2EE Decryption
|
||||||
|
let decryptedText = $state<string | null>(null);
|
||||||
|
let isDecrypting = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (msg.isEncrypted && !decryptedText && !isDecrypting && chat.isEncryptionReady) {
|
||||||
|
isDecrypting = true;
|
||||||
|
chat.decrypt(msg.text).then(result => {
|
||||||
|
decryptedText = result || "⚠️ Decryption failed (Missing or invalid key)";
|
||||||
|
isDecrypting = false;
|
||||||
|
if (onContentLoad) tick().then(onContentLoad);
|
||||||
|
}).catch(e => {
|
||||||
|
console.error("[MessageItem] Decryption error", e);
|
||||||
|
decryptedText = "⚠️ Decryption error";
|
||||||
|
isDecrypting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let tooltip = $state<{
|
let tooltip = $state<{
|
||||||
text: string;
|
text: string;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -270,7 +289,7 @@
|
|||||||
|
|
||||||
<svelte:window onclick={handleGlobalClick} />
|
<svelte:window onclick={handleGlobalClick} />
|
||||||
|
|
||||||
<div class="message-item {isHighlighted ? 'active' : ''} {isThread ? 'thread-message-item' : ''} {isGrouped ? 'grouped' : ''}">
|
<div class="message-item {isHighlighted ? 'active' : ''} {isThread ? 'thread-message-item' : ''} {isGrouped ? 'grouped' : ''} {msg.isEncrypted ? 'is-encrypted' : ''}">
|
||||||
<div class="message-actions-toolbar">
|
<div class="message-actions-toolbar">
|
||||||
{#if chat.isFullyAuthenticated}
|
{#if chat.isFullyAuthenticated}
|
||||||
{#if msg.sender.isEqual(chat.identity!)}
|
{#if msg.sender.isEqual(chat.identity!)}
|
||||||
@@ -393,13 +412,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<RichText
|
{#if msg.isEncrypted}
|
||||||
text={msg.text}
|
{#if isDecrypting}
|
||||||
messageId={msg.id}
|
<div class="decrypting-placeholder">
|
||||||
edited={msg.edited}
|
<i class="fas fa-lock fa-spin"></i> Decrypting...
|
||||||
editedTime={chat.formatTime(msg.sent)}
|
</div>
|
||||||
onLoad={handleImageLoad}
|
{:else if decryptedText}
|
||||||
/>
|
<div class="encrypted-badge-wrapper">
|
||||||
|
<i class="fas fa-lock" title="End-to-end encrypted"></i>
|
||||||
|
<RichText
|
||||||
|
text={decryptedText}
|
||||||
|
messageId={msg.id}
|
||||||
|
edited={msg.edited}
|
||||||
|
editedTime={chat.formatTime(msg.sent)}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="locked-message">
|
||||||
|
<i class="fas fa-lock"></i> <em>Encrypted message. Set up your keys in settings to unlock.</em>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<RichText
|
||||||
|
text={msg.text}
|
||||||
|
messageId={msg.id}
|
||||||
|
edited={msg.edited}
|
||||||
|
editedTime={chat.formatTime(msg.sent)}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -522,6 +564,15 @@
|
|||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-item.is-encrypted {
|
||||||
|
border-left: 2px solid var(--brand);
|
||||||
|
background-color: rgba(88, 101, 242, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.is-encrypted:hover {
|
||||||
|
background-color: rgba(88, 101, 242, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.message-item.active {
|
.message-item.active {
|
||||||
background-color: var(--background-modifier-hover);
|
background-color: var(--background-modifier-hover);
|
||||||
}
|
}
|
||||||
@@ -750,6 +801,37 @@
|
|||||||
animation: pulse 1.5s infinite ease-in-out;
|
animation: pulse 1.5s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locked-message, .decrypting-placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-message i {
|
||||||
|
color: var(--status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encrypted-badge-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encrypted-badge-wrapper > i.fa-lock {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--brand);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% { opacity: 0.5; }
|
0% { opacity: 0.5; }
|
||||||
50% { opacity: 0.8; }
|
50% { opacity: 0.8; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import CustomizationSettings from "./settings/CustomizationSettings.svelte";
|
import CustomizationSettings from "./settings/CustomizationSettings.svelte";
|
||||||
import AudioSettings from "./settings/AudioSettings.svelte";
|
import AudioSettings from "./settings/AudioSettings.svelte";
|
||||||
import ScreenSharingSettings from "./settings/ScreenSharingSettings.svelte";
|
import ScreenSharingSettings from "./settings/ScreenSharingSettings.svelte";
|
||||||
|
import SecuritySettings from "./settings/SecuritySettings.svelte";
|
||||||
|
|
||||||
let { onClose, currentUser }: { onClose: () => void, currentUser: Types.User | undefined } = $props();
|
let { onClose, currentUser }: { onClose: () => void, currentUser: Types.User | undefined } = $props();
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@
|
|||||||
const categories = [
|
const categories = [
|
||||||
{ id: "account", name: "My Account", icon: "fas fa-user" },
|
{ id: "account", name: "My Account", icon: "fas fa-user" },
|
||||||
{ id: "customization", name: "Customization", icon: "fas fa-palette" },
|
{ id: "customization", name: "Customization", icon: "fas fa-palette" },
|
||||||
|
{ id: "security", name: "Security & Privacy", icon: "fas fa-shield-alt" },
|
||||||
{ id: "voice", name: "Voice & Video", icon: "fas fa-microphone" },
|
{ id: "voice", name: "Voice & Video", icon: "fas fa-microphone" },
|
||||||
{ id: "screen", name: "Screen Sharing", icon: "fas fa-desktop" },
|
{ id: "screen", name: "Screen Sharing", icon: "fas fa-desktop" },
|
||||||
];
|
];
|
||||||
@@ -203,6 +205,8 @@
|
|||||||
/>
|
/>
|
||||||
{:else if activeCategory === "customization"}
|
{:else if activeCategory === "customization"}
|
||||||
<CustomizationSettings />
|
<CustomizationSettings />
|
||||||
|
{:else if activeCategory === "security"}
|
||||||
|
<SecuritySettings />
|
||||||
{:else if activeCategory === "voice"}
|
{:else if activeCategory === "voice"}
|
||||||
<AudioSettings />
|
<AudioSettings />
|
||||||
{:else if activeCategory === "screen"}
|
{:else if activeCategory === "screen"}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { ChatService } from "../../services/chat.svelte";
|
||||||
|
|
||||||
|
const chat = getContext<ChatService>("chat");
|
||||||
|
|
||||||
|
let isGenerating = $state(false);
|
||||||
|
let name = $state("");
|
||||||
|
let email = $state("");
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (!name || !email) return;
|
||||||
|
isGenerating = true;
|
||||||
|
try {
|
||||||
|
await chat.generateEncryptionKey(name, email);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to generate keys", e);
|
||||||
|
} finally {
|
||||||
|
isGenerating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPublicKey() {
|
||||||
|
if (chat.myPublicKey) {
|
||||||
|
navigator.clipboard.writeText(chat.myPublicKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>Security & Privacy</h2>
|
||||||
|
<p>Manage your end-to-end encryption keys.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>End-to-End Encryption (GPG)</h3>
|
||||||
|
|
||||||
|
{#if chat.isEncryptionReady}
|
||||||
|
<div class="key-status-card ready">
|
||||||
|
<div class="status-icon">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="status-details">
|
||||||
|
<div class="status-title">E2EE is Active</div>
|
||||||
|
<div class="status-desc">Your messages in Direct Messages will be automatically encrypted.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Your Public Key</label>
|
||||||
|
<div class="key-display">
|
||||||
|
<pre>{chat.myPublicKey}</pre>
|
||||||
|
<button class="btn-secondary btn-small" onclick={copyPublicKey}>
|
||||||
|
<i class="far fa-copy"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="help-text">Share this key with others so they can send you encrypted messages (automatically handled by Zep).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<h4>Danger Zone</h4>
|
||||||
|
<p>Generating a new key will prevent you from reading old encrypted messages.</p>
|
||||||
|
<button class="btn-danger" onclick={() => chat.generateEncryptionKey(name, email)} disabled={isGenerating}>
|
||||||
|
Regenerate Keys
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="key-status-card missing">
|
||||||
|
<div class="status-icon">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="status-details">
|
||||||
|
<div class="status-title">E2EE is Not Set Up</div>
|
||||||
|
<div class="status-desc">You need to generate a keypair to enable private messaging encryption.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-form shadow-box">
|
||||||
|
<h4>Generate New Keypair</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="key-name">Full Name</label>
|
||||||
|
<input id="key-name" type="text" bind:value={name} placeholder="e.g. Alice Smith" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="key-email">Email Address</label>
|
||||||
|
<input id="key-email" type="email" bind:value={email} placeholder="alice@example.com" />
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick={handleGenerate} disabled={isGenerating || !name || !email}>
|
||||||
|
{#if isGenerating}
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> Generating...
|
||||||
|
{:else}
|
||||||
|
Generate Keys
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-content {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
color: var(--header-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h3 {
|
||||||
|
color: var(--header-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-status-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-status-card.ready {
|
||||||
|
background-color: rgba(59, 165, 93, 0.1);
|
||||||
|
border: 1px solid var(--status-positive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-status-card.missing {
|
||||||
|
background-color: rgba(242, 63, 67, 0.1);
|
||||||
|
border: 1px solid var(--status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready .status-icon { color: var(--status-positive); }
|
||||||
|
.missing .status-icon { color: var(--status-danger); }
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--header-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--header-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-display {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--background-secondary-alt);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--background-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-display pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-code);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-display .btn-small {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-form {
|
||||||
|
padding: 24px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-form h4 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--header-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
margin-top: 48px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--status-danger);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone h4 {
|
||||||
|
color: var(--status-danger);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@ import { VoiceService } from "./voice.svelte";
|
|||||||
import { AccountService } from "./account.svelte";
|
import { AccountService } from "./account.svelte";
|
||||||
import { ServerManagementService } from "./server-management.svelte";
|
import { ServerManagementService } from "./server-management.svelte";
|
||||||
import { DirectMessagingService } from "./direct-messaging.svelte";
|
import { DirectMessagingService } from "./direct-messaging.svelte";
|
||||||
|
import { EncryptionService } from "./encryption.svelte";
|
||||||
|
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
#db: DatabaseService;
|
#db: DatabaseService;
|
||||||
@@ -23,6 +24,7 @@ export class ChatService {
|
|||||||
#account: AccountService;
|
#account: AccountService;
|
||||||
#server: ServerManagementService;
|
#server: ServerManagementService;
|
||||||
#dm: DirectMessagingService;
|
#dm: DirectMessagingService;
|
||||||
|
#encryption: EncryptionService;
|
||||||
|
|
||||||
identity = $state<Identity | null>(null);
|
identity = $state<Identity | null>(null);
|
||||||
#blobUrls = new Map<string, string>();
|
#blobUrls = new Map<string, string>();
|
||||||
@@ -45,6 +47,33 @@ export class ChatService {
|
|||||||
this.#account = new AccountService();
|
this.#account = new AccountService();
|
||||||
this.#server = new ServerManagementService();
|
this.#server = new ServerManagementService();
|
||||||
this.#dm = new DirectMessagingService(this.#db, this.#nav, () => this.identity);
|
this.#dm = new DirectMessagingService(this.#db, this.#nav, () => this.identity);
|
||||||
|
this.#encryption = new EncryptionService(this.#db, () => this.identity);
|
||||||
|
|
||||||
|
this.#msg.onSendMessage = async (text, channelId) => {
|
||||||
|
// Check if user has manually enabled encryption for this message/channel
|
||||||
|
if (!this.#msg.encryptionOptIn.has(channelId.toString())) {
|
||||||
|
return { text, isEncrypted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a DM channel
|
||||||
|
const dm = this.#db.directMessages.find(d => d.channelId === channelId);
|
||||||
|
if (dm) {
|
||||||
|
const myIdHex = this.identity?.toHexString();
|
||||||
|
const otherIdentity = dm.sender.toHexString() === myIdHex ? dm.recipient : dm.sender;
|
||||||
|
const recipientPubKey = this.getRecipientPublicKey(otherIdentity);
|
||||||
|
|
||||||
|
if (recipientPubKey && this.isEncryptionReady) {
|
||||||
|
console.log(`[ChatService] Encrypting message for DM ${channelId}`);
|
||||||
|
try {
|
||||||
|
const encrypted = await this.encrypt(text, recipientPubKey);
|
||||||
|
return { text: encrypted, isEncrypted: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ChatService] Encryption failed, sending as plain text", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text, isEncrypted: false };
|
||||||
|
};
|
||||||
|
|
||||||
// Session-only image processing: creates Blob URLs directly from Database data.
|
// Session-only image processing: creates Blob URLs directly from Database data.
|
||||||
// This ditched the persistent IndexedDB cache to prevent stale data between reloads.
|
// This ditched the persistent IndexedDB cache to prevent stale data between reloads.
|
||||||
@@ -334,6 +363,38 @@ export class ChatService {
|
|||||||
return this.#msg.isMessagesReady;
|
return this.#msg.isMessagesReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isEncryptionReady() {
|
||||||
|
return this.#encryption.isKeyReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEncryptionEnabledForChannel = (channelId: bigint) =>
|
||||||
|
this.#msg.encryptionOptIn.has(channelId.toString());
|
||||||
|
|
||||||
|
toggleEncryptionForChannel = (channelId: bigint) => {
|
||||||
|
const chanStr = channelId.toString();
|
||||||
|
if (this.#msg.encryptionOptIn.has(chanStr)) {
|
||||||
|
this.#msg.encryptionOptIn.delete(chanStr);
|
||||||
|
} else {
|
||||||
|
this.#msg.encryptionOptIn.add(chanStr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
get myPublicKey() {
|
||||||
|
return this.#encryption.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateEncryptionKey = (name: string, email: string) =>
|
||||||
|
this.#encryption.generateKeypair(name, email);
|
||||||
|
|
||||||
|
getRecipientPublicKey = (recipientIdentity: Identity) =>
|
||||||
|
this.#encryption.getRecipientPublicKey(recipientIdentity);
|
||||||
|
|
||||||
|
encrypt = (text: string, pubKey: string) =>
|
||||||
|
this.#encryption.encrypt(text, pubKey);
|
||||||
|
|
||||||
|
decrypt = (text: string) =>
|
||||||
|
this.#encryption.decrypt(text);
|
||||||
|
|
||||||
get maxMessageLength() {
|
get maxMessageLength() {
|
||||||
const config = this.#db.systemConfiguration.find(c => c.key === "max_message_length");
|
const config = this.#db.systemConfiguration.find(c => c.key === "max_message_length");
|
||||||
return config ? parseInt(config.value) : 262144;
|
return config ? parseInt(config.value) : 262144;
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export class DirectMessagingService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No session, create it and wait for application to focus
|
// No session, create it and wait for application to focus
|
||||||
this.#openDirectMessageReducer({ recipient }).onApplied(() => {
|
this.#openDirectMessageReducer({ recipient }).then(() => {
|
||||||
// Find the newly created DM
|
// Find the newly created DM
|
||||||
const newDm = this.#db.directMessages.find(dm =>
|
const newDm = this.#db.directMessages.find(dm =>
|
||||||
(dm.sender.isEqual(identity) && dm.recipient.isEqual(recipient)) ||
|
(dm.sender.isEqual(identity) && dm.recipient.isEqual(recipient)) ||
|
||||||
(dm.sender.isEqual(recipient) && dm.identity.isEqual(identity)) // fallback
|
(dm.sender.isEqual(recipient) && dm.recipient.isEqual(identity))
|
||||||
);
|
);
|
||||||
if (newDm) {
|
if (newDm) {
|
||||||
this.#nav.activeServerId = null;
|
this.#nav.activeServerId = null;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import * as openpgp from 'openpgp';
|
||||||
|
import { useReducer } from "spacetimedb/svelte";
|
||||||
|
import { reducers } from "../../module_bindings";
|
||||||
|
import type { DatabaseService } from "./database.svelte";
|
||||||
|
import type { Identity } from "spacetimedb";
|
||||||
|
|
||||||
|
const PRIVATE_KEY_PREFIX = "zep_private_key_";
|
||||||
|
|
||||||
|
export class EncryptionService {
|
||||||
|
#db: DatabaseService;
|
||||||
|
#identity: () => Identity | null;
|
||||||
|
#updatePublicKey = useReducer(reducers.updatePublicKey);
|
||||||
|
|
||||||
|
isKeyReady = $state(false);
|
||||||
|
publicKey = $state<string | null>(null);
|
||||||
|
#privateKey: string | null = null;
|
||||||
|
|
||||||
|
constructor(db: DatabaseService, identity: () => Identity | null) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#identity = identity;
|
||||||
|
|
||||||
|
// Load key from storage if available
|
||||||
|
$effect(() => {
|
||||||
|
const id = this.#identity();
|
||||||
|
if (!id) {
|
||||||
|
this.isKeyReady = false;
|
||||||
|
this.publicKey = null;
|
||||||
|
this.#privateKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idHex = id.toHexString();
|
||||||
|
const storedKey = localStorage.getItem(PRIVATE_KEY_PREFIX + idHex);
|
||||||
|
|
||||||
|
if (storedKey) {
|
||||||
|
this.#privateKey = storedKey;
|
||||||
|
// Verify public key matches what's in DB
|
||||||
|
const user = this.#db.users.find(u => u.identity.isEqual(id));
|
||||||
|
if (user?.publicKey) {
|
||||||
|
this.publicKey = user.publicKey;
|
||||||
|
this.isKeyReady = true;
|
||||||
|
} else {
|
||||||
|
// If we have a private key locally but no public key in DB,
|
||||||
|
// re-sync public key
|
||||||
|
this.extractPublicKeyFromPrivate(storedKey).then(pub => {
|
||||||
|
if (pub) {
|
||||||
|
this.publicKey = pub;
|
||||||
|
this.#updatePublicKey({ publicKey: pub });
|
||||||
|
this.isKeyReady = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateKeypair(name: string, email: string) {
|
||||||
|
const id = this.#identity();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const { privateKey, publicKey } = await openpgp.generateKey({
|
||||||
|
type: 'rsa',
|
||||||
|
rsaBits: 4096,
|
||||||
|
userIDs: [{ name, email }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const idHex = id.toHexString();
|
||||||
|
localStorage.setItem(PRIVATE_KEY_PREFIX + idHex, privateKey);
|
||||||
|
this.#privateKey = privateKey;
|
||||||
|
this.publicKey = publicKey;
|
||||||
|
this.isKeyReady = true;
|
||||||
|
|
||||||
|
this.#updatePublicKey({ publicKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractPublicKeyFromPrivate(armoredPrivKey: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const privateKey = await openpgp.readPrivateKey({ armoredKey: armoredPrivKey });
|
||||||
|
return privateKey.toPublic().armor();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to extract public key", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async encrypt(text: string, recipientPublicKeyArmored: string): Promise<string> {
|
||||||
|
const keys = [];
|
||||||
|
keys.push(await openpgp.readKey({ armoredKey: recipientPublicKeyArmored }));
|
||||||
|
|
||||||
|
// Also encrypt for ourselves if we have a private key, so we can read our sent messages
|
||||||
|
if (this.#privateKey) {
|
||||||
|
const myPrivKey = await openpgp.readPrivateKey({ armoredKey: this.#privateKey });
|
||||||
|
keys.push(myPrivKey.toPublic());
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await openpgp.createMessage({ text });
|
||||||
|
const encrypted = await openpgp.encrypt({
|
||||||
|
message,
|
||||||
|
encryptionKeys: keys,
|
||||||
|
});
|
||||||
|
return encrypted as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(armoredMessage: string): Promise<string | null> {
|
||||||
|
if (!this.#privateKey) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const privateKey = await openpgp.readPrivateKey({ armoredKey: this.#privateKey });
|
||||||
|
const message = await openpgp.readMessage({ armoredMessage });
|
||||||
|
const { data: decrypted } = await openpgp.decrypt({
|
||||||
|
message,
|
||||||
|
decryptionKeys: privateKey,
|
||||||
|
});
|
||||||
|
return decrypted as string;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Decryption failed", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecipientPublicKey(recipientIdentity: Identity): string | null {
|
||||||
|
const user = this.#db.users.find(u => u.identity.isEqual(recipientIdentity));
|
||||||
|
return user?.publicKey || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@ export class MessagingService {
|
|||||||
#extendSubscriptionReducer: any;
|
#extendSubscriptionReducer: any;
|
||||||
#clearUploadStatusReducer: any;
|
#clearUploadStatusReducer: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for ChatService to intercept and potentially encrypt messages.
|
||||||
|
* returns { text: string, isEncrypted: boolean }
|
||||||
|
*/
|
||||||
|
onSendMessage?: (text: string, channelId: bigint) => Promise<{ text: string, isEncrypted: boolean }>;
|
||||||
|
|
||||||
// Internal reactive state from SpacetimeDB
|
// Internal reactive state from SpacetimeDB
|
||||||
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
|
#mySubscriptions = $state<readonly Types.MyChannelSubscriptionRow[]>([]);
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ export class MessagingService {
|
|||||||
isLoadingMore = $state(false);
|
isLoadingMore = $state(false);
|
||||||
#readyChannels = new SvelteSet<bigint>();
|
#readyChannels = new SvelteSet<bigint>();
|
||||||
isGlobalSyncDone = $state(false);
|
isGlobalSyncDone = $state(false);
|
||||||
|
encryptionOptIn = $state(new SvelteSet<string>());
|
||||||
|
|
||||||
get isMessagesReady() {
|
get isMessagesReady() {
|
||||||
const cid = this.#nav.activeChannelId;
|
const cid = this.#nav.activeChannelId;
|
||||||
@@ -283,19 +290,43 @@ export class MessagingService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSendMessage = (text: string, threadId?: bigint, imageIds: bigint[] = []) => {
|
handleSendMessage = async (text: string, threadId?: bigint, imageIds: bigint[] = []) => {
|
||||||
if (this.#nav.activeChannelId) {
|
if (this.#nav.activeChannelId) {
|
||||||
|
let finalParams = { text, isEncrypted: false };
|
||||||
|
if (this.onSendMessage) {
|
||||||
|
finalParams = await this.onSendMessage(text, this.#nav.activeChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
this.#sendMessageReducer({ channelId: this.#nav.activeChannelId, text, threadId, imageIds });
|
this.#sendMessageReducer({
|
||||||
|
channelId: this.#nav.activeChannelId,
|
||||||
|
text: finalParams.text,
|
||||||
|
threadId,
|
||||||
|
imageIds,
|
||||||
|
isEncrypted: finalParams.isEncrypted
|
||||||
|
});
|
||||||
} else if (this.#nav.pendingThreadParentMessageId) {
|
} else if (this.#nav.pendingThreadParentMessageId) {
|
||||||
const parentMsgId = this.#nav.pendingThreadParentMessageId;
|
const parentMsgId = this.#nav.pendingThreadParentMessageId;
|
||||||
const parentMsg = this.allMessages.find((m) => m.id === parentMsgId);
|
const parentMsg = this.allMessages.find((m) => m.id === parentMsgId);
|
||||||
const name = (parentMsg?.text && parentMsg.text.trim().length > 0)
|
const name = (parentMsg?.text && parentMsg.text.trim().length > 0)
|
||||||
? parentMsg.text.substring(0, 32)
|
? parentMsg.text.substring(0, 32)
|
||||||
: "New Thread";
|
: "New Thread";
|
||||||
this.#createThreadWithMessageReducer({ name, channelId: this.#nav.activeChannelId, parentMessageId: parentMsgId, text, imageIds });
|
this.#createThreadWithMessageReducer({
|
||||||
|
name,
|
||||||
|
channelId: this.#nav.activeChannelId,
|
||||||
|
parentMessageId: parentMsgId,
|
||||||
|
text: finalParams.text,
|
||||||
|
imageIds,
|
||||||
|
isEncrypted: finalParams.isEncrypted
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
this.#sendMessageReducer({ channelId: this.#nav.activeChannelId, text, threadId: undefined, imageIds });
|
this.#sendMessageReducer({
|
||||||
|
channelId: this.#nav.activeChannelId,
|
||||||
|
text: finalParams.text,
|
||||||
|
threadId: undefined,
|
||||||
|
imageIds,
|
||||||
|
isEncrypted: finalParams.isEncrypted
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const startStressTest = () => {
|
|||||||
channelId,
|
channelId,
|
||||||
threadId: undefined,
|
threadId: undefined,
|
||||||
imageIds: [],
|
imageIds: [],
|
||||||
|
isEncrypted: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (msgCount % 100 === 0) {
|
if (msgCount % 100 === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user