pgp encryption options for dms

This commit is contained in:
2026-04-09 04:03:52 -04:00
parent 86623d2f6d
commit ed75436d29
19 changed files with 681 additions and 23 deletions
+1
View File
@@ -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"
+9
View File
@@ -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
+2
View File
@@ -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
+27 -7
View File
@@ -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,
}); });
} }
} }
+3
View File
@@ -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)]
+2
View File
@@ -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);
+2
View File
@@ -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
View File
@@ -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;
+7 -1
View File
@@ -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
View File
@@ -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,
+43
View File
@@ -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;
+90 -8
View File
@@ -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; }
+4
View File
@@ -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>
+61
View File
@@ -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;
+2 -2
View File
@@ -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;
+125
View File
@@ -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;
}
}
+35 -4
View File
@@ -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
});
} }
} }
}; };
+1
View File
@@ -63,6 +63,7 @@ const startStressTest = () => {
channelId, channelId,
threadId: undefined, threadId: undefined,
imageIds: [], imageIds: [],
isEncrypted: false,
}); });
if (msgCount % 100 === 0) { if (msgCount % 100 === 0) {