feat: Initialize Electron application with core backend, frontend chat/login UI, and IPC for crypto and link previews.

This commit is contained in:
bryan
2025-12-31 14:26:27 -06:00
parent b26a1d0b4b
commit f531301863
7 changed files with 324 additions and 167 deletions

View File

@@ -38,7 +38,7 @@ io.on('connection', (socket) => {
// Load recent messages // Load recent messages
try { try {
const result = await db.query( const result = await db.query(
`SELECT m.*, u.username `SELECT m.*, u.username, u.public_signing_key
FROM messages m FROM messages m
JOIN users u ON m.sender_id = u.id JOIN users u ON m.sender_id = u.id
WHERE m.channel_id = $1 WHERE m.channel_id = $1
@@ -69,10 +69,11 @@ io.on('connection', (socket) => {
...data ...data
}; };
// Get username for display // Get username and signing key for display/verification
const userRes = await db.query('SELECT username FROM users WHERE id = $1', [senderId]); const userRes = await db.query('SELECT username, public_signing_key FROM users WHERE id = $1', [senderId]);
if (userRes.rows.length > 0) { if (userRes.rows.length > 0) {
message.username = userRes.rows[0].username; message.username = userRes.rows[0].username;
message.public_signing_key = userRes.rows[0].public_signing_key;
} }
// Broadcast to channel // Broadcast to channel

View File

@@ -110,6 +110,15 @@ app.whenReady().then(() => {
return crypto.createHash('sha256').update(data).digest('hex'); return crypto.createHash('sha256').update(data).digest('hex');
}); });
ipcMain.handle('sign-message', (event, privateKeyPem, message) => {
// message should be a string or buffer
return crypto.sign(null, Buffer.from(message), privateKeyPem).toString('hex');
});
ipcMain.handle('verify-signature', (event, publicKeyPem, message, signature) => {
return crypto.verify(null, Buffer.from(message), publicKeyPem, Buffer.from(signature, 'hex'));
});
ipcMain.handle('derive-auth-keys', (event, password, salt) => { ipcMain.handle('derive-auth-keys', (event, password, salt) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => { crypto.scrypt(password, salt, 64, (err, derivedKey) => {

View File

@@ -4,6 +4,8 @@ contextBridge.exposeInMainWorld('cryptoAPI', {
generateKeys: () => ipcRenderer.invoke('generate-keys'), generateKeys: () => ipcRenderer.invoke('generate-keys'),
randomBytes: (size) => ipcRenderer.invoke('random-bytes', size), randomBytes: (size) => ipcRenderer.invoke('random-bytes', size),
sha256: (data) => ipcRenderer.invoke('sha256', data), sha256: (data) => ipcRenderer.invoke('sha256', data),
signMessage: (privateKey, message) => ipcRenderer.invoke('sign-message', privateKey, message),
verifySignature: (publicKey, message, signature) => ipcRenderer.invoke('verify-signature', publicKey, message, signature),
deriveAuthKeys: (password, salt) => ipcRenderer.invoke('derive-auth-keys', password, salt), deriveAuthKeys: (password, salt) => ipcRenderer.invoke('derive-auth-keys', password, salt),
encryptData: (data, key) => ipcRenderer.invoke('encrypt-data', data, key), encryptData: (data, key) => ipcRenderer.invoke('encrypt-data', data, key),
decryptData: (encryptedData, key, iv, tag) => ipcRenderer.invoke('decrypt-data', encryptedData, key, iv, tag), decryptData: (encryptedData, key, iv, tag) => ipcRenderer.invoke('decrypt-data', encryptedData, key, iv, tag),

View File

@@ -5,62 +5,28 @@ import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
const ChatArea = ({ channelId, username }) => { // Cache for link metadata to prevent pop-in
const [messages, setMessages] = useState([]); const metadataCache = new Map();
const [input, setInput] = useState('');
const [socket, setSocket] = useState(null);
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
// Mock Key for demo (32 bytes hex = 64 chars) // Extracted LinkPreview to prevent re-renders on ChatArea updates
const DEMO_CHANNEL_KEY = '000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f'; const LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
// Helper to get consistent color for user const [loading, setLoading] = useState(!metadataCache.has(url));
const getUserColor = (username) => {
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
// Helper to decrypt message
const decryptMessage = async (msg) => {
try {
const TAG_LENGTH = 32;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
return '[Invalid Encrypted Message]';
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
const decrypted = await window.cryptoAPI.decryptData(content, DEMO_CHANNEL_KEY, msg.nonce, tag);
return decrypted;
} catch (e) {
console.error('Decryption failed for msg:', msg.id, e);
return '[Decryption Error]';
}
};
// Helper to extract URLs
const extractUrls = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
};
const LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
useEffect(() => { useEffect(() => {
if (metadataCache.has(url)) {
setMetadata(metadataCache.get(url));
setLoading(false);
return;
}
let isMounted = true; let isMounted = true;
const fetchMeta = async () => { const fetchMeta = async () => {
try { try {
const data = await window.cryptoAPI.fetchMetadata(url); const data = await window.cryptoAPI.fetchMetadata(url);
if (isMounted) { if (isMounted) {
if (data) metadataCache.set(url, data);
setMetadata(data); setMetadata(data);
setLoading(false); setLoading(false);
} }
@@ -121,6 +87,67 @@ const ChatArea = ({ channelId, username }) => {
)} )}
</div> </div>
); );
};
const ChatArea = ({ channelId, channelName, username }) => {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [socket, setSocket] = useState(null);
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
// Mock Key for demo (32 bytes hex = 64 chars)
const DEMO_CHANNEL_KEY = '000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f';
// Helper to get consistent color for user
const getUserColor = (username) => {
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
// Helper to decrypt message
const decryptMessage = async (msg) => {
try {
const TAG_LENGTH = 32;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
return '[Invalid Encrypted Message]';
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
const decrypted = await window.cryptoAPI.decryptData(content, DEMO_CHANNEL_KEY, msg.nonce, tag);
return decrypted;
} catch (e) {
console.error('Decryption failed for msg:', msg.id, e);
return '[Decryption Error]';
}
};
// Helper to extract URLs
const extractUrls = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
};
// Helper to verify message signature
const verifyMessage = async (msg) => {
if (!msg.signature || !msg.public_signing_key) return false;
try {
const isValid = await window.cryptoAPI.verifySignature(
msg.public_signing_key,
msg.ciphertext,
msg.signature
);
return isValid;
} catch (e) {
console.error('Verification error for msg:', msg.id, e);
return false;
}
}; };
useEffect(() => { useEffect(() => {
@@ -130,16 +157,18 @@ const ChatArea = ({ channelId, username }) => {
newSocket.emit('join_channel', channelId); newSocket.emit('join_channel', channelId);
newSocket.on('recent_messages', async (msgs) => { newSocket.on('recent_messages', async (msgs) => {
const decryptedMessages = await Promise.all(msgs.map(async (msg) => { const processedMessages = await Promise.all(msgs.map(async (msg) => {
const content = await decryptMessage(msg); const content = await decryptMessage(msg);
return { ...msg, content }; const isVerified = await verifyMessage(msg);
return { ...msg, content, isVerified };
})); }));
setMessages(decryptedMessages); setMessages(processedMessages);
}); });
newSocket.on('new_message', async (msg) => { newSocket.on('new_message', async (msg) => {
const content = await decryptMessage(msg); const content = await decryptMessage(msg);
setMessages(prev => [...prev, { ...msg, content }]); const isVerified = await verifyMessage(msg);
setMessages(prev => [...prev, { ...msg, content, isVerified }]);
}); });
return () => newSocket.close(); return () => newSocket.close();
@@ -163,20 +192,24 @@ const ChatArea = ({ channelId, username }) => {
try { try {
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(input, DEMO_CHANNEL_KEY); const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(input, DEMO_CHANNEL_KEY);
const ciphertext = encryptedContent + tag; const ciphertext = encryptedContent + tag;
const signature = 'placeholder_signature';
const senderId = localStorage.getItem('userId'); const senderId = localStorage.getItem('userId');
const signingKey = sessionStorage.getItem('signingKey');
if (!senderId) { if (!senderId) {
console.error('No userId found in localStorage'); console.error('No userId found in localStorage');
return; return;
} }
if (!signingKey) {
console.error('No signingKey found in sessionStorage. Please relogin.');
return; // Prevent sending unsigned messages
}
const messageData = { const messageData = {
channelId, channelId,
senderId, senderId,
ciphertext, ciphertext,
nonce: iv, nonce: iv,
signature, signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
keyVersion: 1 keyVersion: 1
}; };
@@ -197,10 +230,26 @@ const ChatArea = ({ channelId, username }) => {
return ( return (
<div className="chat-area"> <div className="chat-area">
<div className="messages-list"> <div className="messages-list">
<div className="messages-content-wrapper">
{messages.map((msg, idx) => { {messages.map((msg, idx) => {
const urls = extractUrls(msg.content); const urls = extractUrls(msg.content);
const currentDate = new Date(msg.created_at);
const previousDate = idx > 0 ? new Date(messages[idx - 1].created_at) : null;
const isNewDay = !previousDate || (
currentDate.getDate() !== previousDate.getDate() ||
currentDate.getMonth() !== previousDate.getMonth() ||
currentDate.getFullYear() !== previousDate.getFullYear()
);
return ( return (
<div key={idx} className="message-item"> <React.Fragment key={idx}>
{isNewDay && (
<div className="date-divider">
<span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span>
</div>
)}
<div className="message-item">
<div className="message-avatar-wrapper"> <div className="message-avatar-wrapper">
<div <div
className="message-avatar" className="message-avatar"
@@ -214,8 +263,15 @@ const ChatArea = ({ channelId, username }) => {
<span className="username" style={{ color: getUserColor(msg.username || 'Unknown') }}> <span className="username" style={{ color: getUserColor(msg.username || 'Unknown') }}>
{msg.username || 'Unknown'} {msg.username || 'Unknown'}
</span> </span>
{!msg.isVerified && (
<span className="verification-failed" title="Signature Verification Failed!">
<svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path>
</svg>
</span>
)}
<span className="timestamp"> <span className="timestamp">
{new Date(msg.created_at).toLocaleDateString()} at {new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span> </span>
</div> </div>
<div className="message-content"> <div className="message-content">
@@ -269,10 +325,12 @@ const ChatArea = ({ channelId, username }) => {
</div> </div>
</div> </div>
</div> </div>
</React.Fragment>
); );
})} })}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</div>
<form className="chat-input-form" onSubmit={handleSend}> <form className="chat-input-form" onSubmit={handleSend}>
<div className="chat-input-wrapper"> <div className="chat-input-wrapper">
<button type="button" className="chat-input-file-btn"> <button type="button" className="chat-input-file-btn">
@@ -285,7 +343,7 @@ const ChatArea = ({ channelId, username }) => {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={`Message #${channelId}`} placeholder={`Message #${channelName || channelId}`}
rows={1} rows={1}
style={{ height: '44px' }} style={{ height: '44px' }}
/> />

View File

@@ -1,7 +1,8 @@
:root { :root {
--bg-primary: #36393f; --bg-primary: #1a1a1e;
--bg-secondary: #2f3136; --bg-secondary: #121214;
--bg-tertiary: #202225; --bg-tertiary: #121214;
--div-border: #222225;
--text-normal: #dcddde; --text-normal: #dcddde;
--text-muted: #72767d; --text-muted: #72767d;
--header-primary: #ffffff; --header-primary: #ffffff;
@@ -18,7 +19,8 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: 'Whitney', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
;
background-color: var(--bg-primary); background-color: var(--bg-primary);
color: var(--text-normal); color: var(--text-normal);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -125,15 +127,18 @@ body {
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-shrink: 0;
} }
.server-list { .server-list {
width: 72px; width: 72px;
border-right: 1px solid var(--div-border);
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 12px; padding-top: 12px;
flex-shrink: 0;
} }
.server-icon { .server-icon {
@@ -194,6 +199,8 @@ body {
display: flex; display: flex;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
overflow: hidden;
/* Ensure no scrollbars on body/container */
} }
.chat-area { .chat-area {
@@ -202,6 +209,8 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
min-width: 0;
/* CRITICAL: Allows flex item to shrink below content size */
} }
.messages-list { .messages-list {
@@ -263,7 +272,7 @@ body {
} }
.username { .username {
font-size: 16px; font-size: 1rem;
font-weight: 500; font-weight: 500;
margin-right: 0.25rem; margin-right: 0.25rem;
cursor: pointer; cursor: pointer;
@@ -348,7 +357,7 @@ body {
} }
.chat-input-wrapper { .chat-input-wrapper {
background-color: #40444b; background-color: #222327;
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -427,7 +436,11 @@ body {
padding: 10px; padding: 10px;
margin-top: 8px; margin-top: 8px;
max-width: 520px; max-width: 520px;
width: 100%;
gap: 16px; gap: 16px;
overflow: hidden;
/* Ensure content stays inside */
box-sizing: border-box;
} }
.preview-content { .preview-content {
@@ -451,21 +464,31 @@ body {
text-decoration: none; text-decoration: none;
margin-bottom: 4px; margin-bottom: 4px;
display: block; display: block;
white-space: nowrap; word-wrap: break-word;
overflow: hidden; /* Ensure long words don't overflow */
text-overflow: ellipsis;
} }
.preview-title:hover { .preview-title:hover {
text-decoration: underline; text-decoration: underline;
} }
/* .messages-content-wrapper */
.messages-content-wrapper {
/* Use margin-top: auto to push content to bottom safely */
margin-top: auto;
display: flex;
flex-direction: column;
}
/* ... existing styles ... */
.preview-description { .preview-description {
font-size: 14px; font-size: 14px;
color: #dcddde; color: #dcddde;
line-height: 1.4; line-height: 1.4;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@@ -539,3 +562,37 @@ body {
height: 100%; height: 100%;
border: 0; border: 0;
} }
.date-divider {
display: flex;
align-items: center;
justify-content: center;
margin: 24px 16px 8px 16px;
position: relative;
}
.date-divider::before {
content: "";
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background-color: #42454a;
z-index: 1;
}
.date-divider span {
background-color: var(--bg-primary);
padding: 0 8px;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
.verification-failed {
display: flex;
align-items: center;
margin-right: 0.25rem;
cursor: help;
}

View File

@@ -27,7 +27,10 @@ const Chat = () => {
activeChannel={activeChannel} activeChannel={activeChannel}
onSelectChannel={setActiveChannel} onSelectChannel={setActiveChannel}
/> />
<ChatArea channelId={activeChannel} /> <ChatArea
channelId={activeChannel}
channelName={channels.find(c => c.id === activeChannel)?.name || activeChannel}
/>
</div> </div>
); );
}; };

View File

@@ -51,6 +51,33 @@ const Login = () => {
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData); console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
} }
// 4. Decrypt Master Key (using DEK)
console.log('Decrypting Master Key...');
const encryptedMKObj = JSON.parse(verifyData.encryptedMK);
const mkHex = await window.cryptoAPI.decryptData(
encryptedMKObj.content,
dek,
encryptedMKObj.iv,
encryptedMKObj.tag
);
// 5. Decrypt Private Keys (using MK)
console.log('Decrypting Private Keys...');
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
// Decrypt Ed25519 Signing Key
const edPrivObj = encryptedPrivateKeysObj.ed; // Already an object
const signingKey = await window.cryptoAPI.decryptData(
edPrivObj.content,
mkHex, // MK acts as the key
edPrivObj.iv,
edPrivObj.tag
);
// Store Signing Key in Session (Memory-like) storage
sessionStorage.setItem('signingKey', signingKey);
console.log('Signing Key decrypted and stored in session.');
localStorage.setItem('username', username); localStorage.setItem('username', username);
// Verify immediate read back // Verify immediate read back