diff --git a/core/.cargo/config.toml b/core/.cargo/config.toml new file mode 100644 index 0000000..8831040 --- /dev/null +++ b/core/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--allow", "deprecated"] diff --git a/core/Cargo.lock b/core/Cargo.lock index 505ff79..8d89438 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -376,17 +376,22 @@ name = "commonchat-core" version = "0.1.0" dependencies = [ "bytes", + "chacha20poly1305", "commonware-codec", "commonware-cryptography", "commonware-math", "commonware-p2p", + "curve25519-dalek", "getrandom 0.2.17", "hex", + "hkdf", "rand 0.8.5", "serde", "serde_json", + "sha2 0.10.9", "tokio", "wasm-bindgen", + "x25519-dalek", ] [[package]] @@ -1142,6 +1147,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" diff --git a/core/Cargo.toml b/core/Cargo.toml index a9c72bd..e678cfd 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -16,6 +16,13 @@ serde_json = "1.0" hex = "0.4" rand = "0.8" +# Cryptography: x25519, ed25519, chacha20poly1305, hkdf, sha2 +x25519-dalek = { version = "2", features = ["static_secrets"] } +curve25519-dalek = "4" +chacha20poly1305 = "0.10" +hkdf = "0.12" +sha2 = "0.10" + # Native (non-WASM): tam cryptography + p2p + tokio [target.'cfg(not(target_arch = "wasm32"))'.dependencies] commonware-cryptography = { path = "../commonware-source/cryptography" } @@ -24,5 +31,7 @@ tokio = { version = "1", features = ["full"] } # WASM (tarayıcı): blst yok (sadece ed25519), getrandom "js" [target.'cfg(target_arch = "wasm32")'.dependencies] -commonware-cryptography = { path = "../commonware-source/cryptography", default-features = false, features = ["std"] } -getrandom = { version = "0.2", features = ["js"] } \ No newline at end of file +commonware-cryptography = { path = "../commonware-source/cryptography", default-features = false, features = [ + "std", +] } +getrandom = { version = "0.2", features = ["js"] } diff --git a/core/src/lib.rs b/core/src/lib.rs index 859bc5e..fb2207c 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,10 +1,15 @@ +use chacha20poly1305::{aead::Aead, AeadCore, ChaCha20Poly1305, KeyInit}; use commonware_codec::{FixedSize, Read, Write}; use commonware_cryptography::ed25519; use commonware_cryptography::Signer; use commonware_cryptography::Verifier; use commonware_math::algebra::Random; +use curve25519_dalek::edwards::CompressedEdwardsY; use hex; +use hkdf::Hkdf; +use sha2::{Digest, Sha256, Sha512}; use wasm_bindgen::prelude::*; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; const CHAT_NAMESPACE: &[u8] = b"commonchat.v1"; @@ -25,18 +30,132 @@ impl CommonwareIdentity { /// Signs the message with Ed25519; returns the signature as a hex string. #[wasm_bindgen] pub fn sign(&self, message: &str) -> String { - let sig = self - .private_key - .sign(CHAT_NAMESPACE, message.as_bytes()); + let sig = self.private_key.sign(CHAT_NAMESPACE, message.as_bytes()); hex::encode(sig.as_ref()) } /// Exports the private key as a hex string (for localStorage). #[wasm_bindgen] pub fn export_private_hex(&self) -> String { + hex::encode(self.ed25519_seed_bytes()) + } + + #[wasm_bindgen] + pub fn get_x25519_public_key(&self) -> String { + let seed = self.ed25519_seed_bytes(); + + // SHA-512(seed) → 64 bytes, take first 32 + let hash = Sha512::digest(&seed); + let mut x25519_input = [0u8; 32]; + x25519_input.copy_from_slice(&hash[..32]); + + // StaticSecret::from() clamps the scalar internally + let secret = StaticSecret::from(x25519_input); + let public = X25519PublicKey::from(&secret); + + hex::encode(public.as_bytes()) + } + + #[wasm_bindgen] + pub fn encrypt_for_peer( + &self, + recipient_x25519_pub_hex: &str, + plaintext: &str, + ) -> Result { + // Decode recipient's X25519 public key from hex + let recip_bytes = hex::decode(recipient_x25519_pub_hex) + .map_err(|e| JsError::new(&format!("invalid recipient pub hex: {}", e)))?; + if recip_bytes.len() != 32 { + return Err(JsError::new("recipient X25519 pub key must be 32 bytes")); + } + let mut recip_arr = [0u8; 32]; + recip_arr.copy_from_slice(&recip_bytes); + let recip_pub = X25519PublicKey::from(recip_arr); + + // Derive our X25519 secret from Ed25519 seed + let hash = Sha512::digest(&self.ed25519_seed_bytes()); + let mut x_input = [0u8; 32]; + x_input.copy_from_slice(&hash[..32]); + let my_secret = StaticSecret::from(x_input); + + // ECDH → shared secret + let shared = my_secret.diffie_hellman(&recip_pub); + + // HKDF-SHA256 → encryption key + let hk = Hkdf::::new(None, shared.as_bytes()); + let mut enc_key = [0u8; 32]; + hk.expand(b"commonchat.e2e.v1", &mut enc_key) + .map_err(|_| JsError::new("HKDF expand failed"))?; + + // ChaCha20-Poly1305 encrypt + let cipher = ChaCha20Poly1305::new((&enc_key).into()); + let nonce = ChaCha20Poly1305::generate_nonce(&mut rand::thread_rng()); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|_| JsError::new("encryption failed"))?; + + // Return hex(nonce ‖ ciphertext) + let mut combined = Vec::with_capacity(12 + ciphertext.len()); + combined.extend_from_slice(&nonce); + combined.extend_from_slice(&ciphertext); + Ok(hex::encode(combined)) + } + + #[wasm_bindgen] + pub fn decrypt_from_peer( + &self, + sender_x25519_pub_hex: &str, + encrypted_hex: &str, + ) -> Result { + // Decode the combined nonce+ciphertext + let combined = hex::decode(encrypted_hex) + .map_err(|e| JsError::new(&format!("invalid encrypted hex: {}", e)))?; + if combined.len() < 12 + 16 { + // 12-byte nonce + at least 16-byte Poly1305 tag + return Err(JsError::new("ciphertext too short")); + } + let (nonce_bytes, ciphertext) = combined.split_at(12); + + // Decode sender's X25519 public key + let sender_bytes = hex::decode(sender_x25519_pub_hex) + .map_err(|e| JsError::new(&format!("invalid sender pub hex: {}", e)))?; + if sender_bytes.len() != 32 { + return Err(JsError::new("sender X25519 pub key must be 32 bytes")); + } + let mut sender_arr = [0u8; 32]; + sender_arr.copy_from_slice(&sender_bytes); + let sender_pub = X25519PublicKey::from(sender_arr); + + // Derive our X25519 secret (same as encrypt) + let hash = Sha512::digest(&self.ed25519_seed_bytes()); + let mut x_input = [0u8; 32]; + x_input.copy_from_slice(&hash[..32]); + let my_secret = StaticSecret::from(x_input); + + // ECDH → same shared secret + let shared = my_secret.diffie_hellman(&sender_pub); + + // HKDF → same encryption key + let hk = Hkdf::::new(None, shared.as_bytes()); + let mut enc_key = [0u8; 32]; + hk.expand(b"commonchat.e2e.v1", &mut enc_key) + .map_err(|_| JsError::new("HKDF expand failed"))?; + + // Decrypt + let cipher = ChaCha20Poly1305::new((&enc_key).into()); + let plaintext = cipher + .decrypt(nonce_bytes.into(), ciphertext) + .map_err(|_| JsError::new("decryption failed (wrong key or tampered)"))?; + + String::from_utf8(plaintext).map_err(|_| JsError::new("decrypted data is not valid UTF-8")) + } + + fn ed25519_seed_bytes(&self) -> [u8; 32] { let mut buf = bytes::BytesMut::new(); Write::write(&self.private_key, &mut buf); - hex::encode(&buf) + let mut seed = [0u8; 32]; + seed.copy_from_slice(&buf); + seed } } @@ -58,8 +177,8 @@ pub fn verify_signature( message: &str, signature_hex: &str, ) -> Result { - let pub_bytes = - hex::decode(pub_key_hex).map_err(|e| JsError::new(&format!("invalid pub key hex: {}", e)))?; + let pub_bytes = hex::decode(pub_key_hex) + .map_err(|e| JsError::new(&format!("invalid pub key hex: {}", e)))?; if pub_bytes.len() != ed25519::PublicKey::SIZE { return Err(JsError::new("invalid pub key length")); } @@ -77,6 +196,27 @@ pub fn verify_signature( Ok(public_key.verify(CHAT_NAMESPACE, message.as_bytes(), &signature)) } +/// Converts any Ed25519 public key to its X25519 equivalent (Edwards → Montgomery). +#[wasm_bindgen] +pub fn ed25519_pub_to_x25519_pub(ed25519_pub_hex: &str) -> Result { + let pub_bytes = + hex::decode(ed25519_pub_hex).map_err(|e| JsError::new(&format!("invalid hex: {}", e)))?; + if pub_bytes.len() != 32 { + return Err(JsError::new("ed25519 public key must be 32 bytes")); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&pub_bytes); + + // Decompress the Edwards point, then convert to Montgomery form + let edwards = CompressedEdwardsY(arr); + let point = edwards + .decompress() + .ok_or_else(|| JsError::new("invalid Ed25519 public key point"))?; + let montgomery = point.to_montgomery(); + + Ok(hex::encode(montgomery.to_bytes())) +} + /// Restores identity from private key hex (e.g. read from localStorage). #[wasm_bindgen] pub fn identity_from_private_hex(hex_str: &str) -> Result { diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/frontend/app/hooks/useIdentity.ts b/frontend/app/hooks/useIdentity.ts index ff00bac..405d8cd 100644 --- a/frontend/app/hooks/useIdentity.ts +++ b/frontend/app/hooks/useIdentity.ts @@ -20,13 +20,25 @@ export function useIdentity() { sign: (msg: string) => string; pub_key: string; export_private_hex: () => string; + get_x25519_public_key: () => string; + encrypt_for_peer: (recipientX25519PubHex: string, plaintext: string) => string; + decrypt_from_peer: (senderX25519PubHex: string, encryptedHex: string) => string; } | null>(null); + type IdentityInstance = { + sign: (m: string) => string; + pub_key: string; + export_private_hex: () => string; + get_x25519_public_key: () => string; + encrypt_for_peer: (recipientX25519PubHex: string, plaintext: string) => string; + decrypt_from_peer: (senderX25519PubHex: string, encryptedHex: string) => string; + }; type CoreModule = { - default?: () => Promise; - create_identity: () => { sign: (m: string) => string; pub_key: string; export_private_hex: () => string }; - identity_from_private_hex: (hex: string) => { sign: (m: string) => string; pub_key: string; export_private_hex: () => string }; - verify_signature: (pub: string, msg: string, sig: string) => boolean; -}; + default?: () => Promise; + create_identity: () => IdentityInstance; + identity_from_private_hex: (hex: string) => IdentityInstance; + verify_signature: (pub: string, msg: string, sig: string) => boolean; + ed25519_pub_to_x25519_pub: (ed25519PubHex: string) => string; + }; const coreRef = useRef(null); const loadStored = useCallback(async () => { @@ -105,6 +117,30 @@ export function useIdentity() { [] ); + const getX25519PublicKey = useCallback((): string | null => { + const id = identityRef.current; + if (!id) return null; + return id.get_x25519_public_key(); +}, []); + + const encryptForPeer = useCallback((recipientX25519PubHex: string, plaintext: string): string | null => { + const id = identityRef.current; + if (!id) return null; + return id.encrypt_for_peer(recipientX25519PubHex, plaintext); + }, []); + + const decryptFromPeer = useCallback((senderX25519PubHex: string, encryptedHex: string): string | null => { + const id = identityRef.current; + if (!id) return null; + return id.decrypt_from_peer(senderX25519PubHex, encryptedHex); + }, []); + + const ed25519PubToX25519Pub = useCallback((ed25519PubHex: string): string | null => { + const c = coreRef.current; + if (!c) return null; + return c.ed25519_pub_to_x25519_pub(ed25519PubHex); + }, []); + const updateDisplayName = useCallback((newName: string) => { setDisplayName(newName); const raw = typeof window !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null; @@ -116,14 +152,18 @@ export function useIdentity() { }, []); return { - displayName, - setDisplayName: updateDisplayName, - peerId, - signMessage, - verifySignature, - isReady, - isInitialized, - initializeWithDisplayName, - error, - }; + displayName, + setDisplayName: updateDisplayName, + peerId, + signMessage, + verifySignature, + getX25519PublicKey, + encryptForPeer, + decryptFromPeer, + ed25519PubToX25519Pub, + isReady, + isInitialized, + initializeWithDisplayName, + error, +}; } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0a51661..5688b05 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -12,808 +12,995 @@ type MessageType = "general" | "private"; export type PublicRoomId = "general" | "commonware" | "sloth"; const PUBLIC_ROOMS: { id: PublicRoomId; label: string; emoji: string }[] = [ - { id: "general", label: "General Chat", emoji: "🌐" }, - { id: "commonware", label: "Commonware Community", emoji: "🦀" }, - { id: "sloth", label: "Celestine Sloth Community", emoji: "🦥" }, + { id: "general", label: "General Chat", emoji: "🌐" }, + { id: "commonware", label: "Commonware Community", emoji: "🦀" }, + { id: "sloth", label: "Celestine Sloth Community", emoji: "🦥" }, ]; interface ChatMessage { - id: string; - text: string; - signatureHex: string; - fromMe: boolean; - displayName: string; - peerId: string; - recipient: string; - at: number; - type?: MessageType; - roomId?: string; + id: string; + text: string; + signatureHex: string; + fromMe: boolean; + displayName: string; + peerId: string; + recipient: string; + at: number; + type?: MessageType; + roomId?: string; } function formatTime(at: number): string { - const d = new Date(at); - return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + const d = new Date(at); + return d.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); } function truncate(s: string, len = 12) { - if (s.length <= len) return s; - return s.slice(0, len) + "…"; + if (s.length <= len) return s; + return s.slice(0, len) + "…"; } function peerIdShort(peerId: string, head = 4, tail = 4): string { - const s = peerId.replace(/^0x/i, ""); - if (s.length <= head + tail) return peerId; - return `${s.slice(0, head)}…${s.slice(-tail)}`; + const s = peerId.replace(/^0x/i, ""); + if (s.length <= head + tail) return peerId; + return `${s.slice(0, head)}…${s.slice(-tail)}`; } function getPeerByPubKey( - pubKey: string, - peers: { id: string; name: string; pubKey: string }[] + pubKey: string, + peers: { id: string; name: string; pubKey: string }[], ) { - return peers.find((p) => p.pubKey === pubKey); + return peers.find((p) => p.pubKey === pubKey); } function getPeerByName( - name: string, - peers: { id: string; name: string; pubKey: string }[] + name: string, + peers: { id: string; name: string; pubKey: string }[], ) { - const q = name.trim().toLowerCase(); - if (!q) return null; - return peers.find((p) => p.name.toLowerCase() === q) ?? null; + const q = name.trim().toLowerCase(); + if (!q) return null; + return peers.find((p) => p.name.toLowerCase() === q) ?? null; } function resolveRecipientId( - raw: string, - peers: { id: string; name: string; pubKey: string }[] + raw: string, + peers: { id: string; name: string; pubKey: string }[], ): string { - const r = raw.trim(); - if (!r) return ""; - const peer = getPeerByName(r, peers); - return peer ? peer.pubKey : r; + const r = raw.trim(); + if (!r) return ""; + const peer = getPeerByName(r, peers); + return peer ? peer.pubKey : r; } function resolveRecipientDisplay( - recipientId: string, - peers: { id: string; name: string; pubKey: string }[] + recipientId: string, + peers: { id: string; name: string; pubKey: string }[], ): string { - if (!recipientId) return ""; - const peer = getPeerByPubKey(recipientId, peers); - return peer ? `${peer.name} (${truncate(recipientId, 10)})` : truncate(recipientId, 24); + if (!recipientId) return ""; + const peer = getPeerByPubKey(recipientId, peers); + return peer + ? `${peer.name} (${truncate(recipientId, 10)})` + : truncate(recipientId, 24); } function resolveRecipientLabel( - recipientId: string, - peers: { id: string; name: string; pubKey: string }[] + recipientId: string, + peers: { id: string; name: string; pubKey: string }[], ): string { - if (!recipientId) return "Broadcast"; - const peer = getPeerByPubKey(recipientId, peers); - return peer ? peer.name : truncate(recipientId, 16); + if (!recipientId) return "Broadcast"; + const peer = getPeerByPubKey(recipientId, peers); + return peer ? peer.name : truncate(recipientId, 16); } function getPreviousPeers( - messages: ChatMessage[], - myPeerId: string, - onlinePeers: { id: string; name: string; pubKey: string }[] + messages: ChatMessage[], + myPeerId: string, + onlinePeers: { id: string; name: string; pubKey: string }[], ): { id: string; name: string; pubKey: string; online: boolean }[] { - const peerNames = new Map(); - for (const m of messages) { - const other = m.fromMe ? (m.recipient !== "Broadcast" ? m.recipient : null) : m.peerId; - if (other && other !== myPeerId) { - if (!m.fromMe && m.displayName) peerNames.set(other, m.displayName); - } - } - const seen = new Set(); - const result: { id: string; name: string; pubKey: string; online: boolean }[] = []; - for (const m of messages) { - const other = m.fromMe ? (m.recipient !== "Broadcast" ? m.recipient : null) : m.peerId; - if (other && other !== myPeerId && !seen.has(other)) { - seen.add(other); - const online = onlinePeers.find((p) => p.pubKey === other); - result.push({ - id: other, - pubKey: other, - name: online?.name ?? peerNames.get(other) ?? truncate(other, 12), - online: !!online, - }); - } - } - return result.sort((a, b) => (a.online === b.online ? 0 : a.online ? -1 : 1)); + const peerNames = new Map(); + for (const m of messages) { + const other = m.fromMe + ? m.recipient !== "Broadcast" + ? m.recipient + : null + : m.peerId; + if (other && other !== myPeerId) { + if (!m.fromMe && m.displayName) peerNames.set(other, m.displayName); + } + } + const seen = new Set(); + const result: { + id: string; + name: string; + pubKey: string; + online: boolean; + }[] = []; + for (const m of messages) { + const other = m.fromMe + ? m.recipient !== "Broadcast" + ? m.recipient + : null + : m.peerId; + if (other && other !== myPeerId && !seen.has(other)) { + seen.add(other); + const online = onlinePeers.find((p) => p.pubKey === other); + result.push({ + id: other, + pubKey: other, + name: online?.name ?? peerNames.get(other) ?? truncate(other, 12), + online: !!online, + }); + } + } + return result.sort((a, b) => (a.online === b.online ? 0 : a.online ? -1 : 1)); } function getDirectMessagesPeerList( - onlinePeers: { id: string; name: string; pubKey: string }[], - previousPeers: { id: string; name: string; pubKey: string; online: boolean }[], - myPeerId: string + onlinePeers: { id: string; name: string; pubKey: string }[], + previousPeers: { + id: string; + name: string; + pubKey: string; + online: boolean; + }[], + myPeerId: string, ): { id: string; name: string; pubKey: string; online: boolean }[] { - const byKey = new Map(); - for (const p of onlinePeers) { - if (p.pubKey === myPeerId) continue; - byKey.set(p.pubKey, { id: p.id, name: p.name, pubKey: p.pubKey, online: true }); - } - for (const p of previousPeers) { - if (p.pubKey === myPeerId) continue; - if (!byKey.has(p.pubKey)) byKey.set(p.pubKey, { ...p, online: false }); - } - return Array.from(byKey.values()).sort((a, b) => (a.online === b.online ? 0 : a.online ? -1 : 1)); + const byKey = new Map< + string, + { id: string; name: string; pubKey: string; online: boolean } + >(); + for (const p of onlinePeers) { + if (p.pubKey === myPeerId) continue; + byKey.set(p.pubKey, { + id: p.id, + name: p.name, + pubKey: p.pubKey, + online: true, + }); + } + for (const p of previousPeers) { + if (p.pubKey === myPeerId) continue; + if (!byKey.has(p.pubKey)) byKey.set(p.pubKey, { ...p, online: false }); + } + return Array.from(byKey.values()).sort((a, b) => + a.online === b.online ? 0 : a.online ? -1 : 1, + ); } export default function Home() { - const { - displayName, - setDisplayName, - peerId, - signMessage, - verifySignature, - isReady, - isInitialized, - initializeWithDisplayName, - error, - } = useIdentity(); - - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [editingName, setEditingName] = useState(false); - const [editNameValue, setEditNameValue] = useState(displayName); - - const [operatorName, setOperatorName] = useState(""); - const [modalClosing, setModalClosing] = useState(false); - const [selectedRecipient, setSelectedRecipient] = useState(""); - const [p2pNotification, setP2pNotification] = useState(false); - const [onlinePeers, setOnlinePeers] = useState<{ id: string; name: string; pubKey: string }[]>([]); - const [relayConnected, setRelayConnected] = useState(false); - const socketRef = useRef(null); - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - - type ViewMode = "public" | "direct"; - const [activeView, setActiveView] = useState("public"); - const [activePublicRoomId, setActivePublicRoomId] = useState("general"); - const [activeDirectPeerId, setActiveDirectPeerId] = useState(null); - const [unreadPeers, setUnreadPeers] = useState>(new Set()); - const [tabUnreadCount, setTabUnreadCount] = useState(0); - const baseTitleRef = useRef("CommonChat"); - const activeViewRef = useRef("public"); - const activePublicRoomIdRef = useRef("general"); - const activeDirectPeerIdRef = useRef(null); - useEffect(() => { - activeViewRef.current = activeView; - activePublicRoomIdRef.current = activePublicRoomId; - activeDirectPeerIdRef.current = activeDirectPeerId; - }, [activeView, activePublicRoomId, activeDirectPeerId]); - - const previousPeers = getPreviousPeers(messages, peerId, onlinePeers); - const directMessagesPeerList = getDirectMessagesPeerList(onlinePeers, previousPeers, peerId); - - const isPublicRoomMessage = (m: ChatMessage, roomId: PublicRoomId) => { - const t = m.type ?? ((m.recipient ?? "Broadcast") === "Broadcast" ? "general" : "private"); - if (t !== "general" || (m.recipient ?? "Broadcast") !== "Broadcast") return false; - const msgRoom = (m.roomId ?? "general") as PublicRoomId; - return msgRoom === roomId; - }; - const isPrivateMessageWith = (m: ChatMessage, otherPeerId: string) => { - const t = m.type ?? ((m.recipient ?? "Broadcast") === "Broadcast" ? "general" : "private"); - if (t !== "private") return false; - return (m.fromMe && (m.recipient ?? "") === otherPeerId) || (!m.fromMe && m.peerId === otherPeerId); - }; - const visibleMessages: ChatMessage[] = - activeView === "public" - ? messages.filter((m) => isPublicRoomMessage(m, activePublicRoomId)) - : activeDirectPeerId - ? messages.filter((m) => isPrivateMessageWith(m, activeDirectPeerId)) - : []; - - const openPublicRoom = useCallback((roomId: PublicRoomId) => { - setActiveView("public"); - setActivePublicRoomId(roomId); - setActiveDirectPeerId(null); - setSelectedRecipient(""); - }, []); - const openDirectWith = useCallback((peerPubKey: string) => { - setActiveView("direct"); - setActiveDirectPeerId(peerPubKey); - setSelectedRecipient(peerPubKey); - setUnreadPeers((prev) => { - const next = new Set(prev); - next.delete(peerPubKey); - return next; - }); - }, []); - - const storageKey = peerId ? `${MESSAGES_STORAGE_KEY}_${peerId}` : null; - - useEffect(() => { - if (!storageKey || typeof window === "undefined") return; - try { - const raw = localStorage.getItem(storageKey); - if (raw) { - const parsed = JSON.parse(raw) as ChatMessage[]; - if (Array.isArray(parsed) && parsed.length > 0) { - setMessages(parsed); - } - } - } catch { - // ignore - } - }, [storageKey]); - - useEffect(() => { - if (!storageKey || messages.length === 0) return; - try { - const toStore = messages.slice(-500); - localStorage.setItem(storageKey, JSON.stringify(toStore)); - } catch { - // quota or parse error - } - }, [storageKey, messages]); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [visibleMessages]); - - const handleInitialize = useCallback(() => { - const name = operatorName.trim(); - if (!name) return; - setModalClosing(true); - setTimeout(() => { - initializeWithDisplayName(name); - setModalClosing(false); - }, 220); - }, [operatorName, initializeWithDisplayName]); - - const handleSignAndSend = useCallback(() => { - const text = input.trim(); - if (!text || !isInitialized) return; - const sig = signMessage(text); - if (sig == null) return; - const isPrivate = activeView === "direct" && !!activeDirectPeerId; - const recipient = isPrivate - ? (activeDirectPeerId || resolveRecipientId(selectedRecipient, onlinePeers) || selectedRecipient.trim() || "") - : "Broadcast"; - if (isPrivate && !recipient) return; - const msg: ChatMessage = { - id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, - text, - signatureHex: sig, - fromMe: true, - displayName, - peerId, - recipient: recipient || "Broadcast", - at: Date.now(), - type: isPrivate ? "private" : "general", - roomId: activeView === "public" ? activePublicRoomId : undefined, - }; - socketRef.current?.emit("message", msg); - setMessages((prev) => [...prev, msg]); - setInput(""); - requestAnimationFrame(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }); - }, [input, isInitialized, signMessage, displayName, peerId, activeView, activePublicRoomId, activeDirectPeerId, selectedRecipient, onlinePeers]); - - useEffect(() => { - if (!isInitialized || !peerId || !displayName) return; - const socket = io(RELAY_URL, { autoConnect: true }); - socketRef.current = socket; - - socket.on("connect", () => { - setRelayConnected(true); - socket.emit("register", { peerId, displayName }); - }); - socket.on("disconnect", () => setRelayConnected(false)); - - socket.on("online_list", (list: { id: string; name: string; pubKey: string }[]) => { - setOnlinePeers(Array.isArray(list) ? list : []); - }); - socket.on( - "user_online", - (user: { peerId?: string; id?: string; name?: string; pubKey?: string; displayName?: string }) => { - const pId = user.peerId ?? user.id ?? user.pubKey; - if (!pId || pId === peerId) return; - const name = user.name ?? user.displayName ?? truncate(pId, 12); - setOnlinePeers((prev) => { - const without = prev.filter((p) => p.pubKey !== pId); - return [...without, { id: pId, name, pubKey: pId }]; - }); - } - ); - socket.on("user_offline", (data: { peerId: string }) => { - if (data.peerId === peerId) return; - setOnlinePeers((prev) => prev.filter((p) => p.pubKey !== data.peerId)); - }); - - socket.on("message", async (chatMsg: ChatMessage) => { - const myId = peerId; - console.log("Incoming message:", chatMsg); - - if (!chatMsg?.text || !chatMsg?.signatureHex || !chatMsg?.peerId) return; - const valid = await verifySignature( - chatMsg.peerId, - chatMsg.text, - chatMsg.signatureHex - ); - if (!valid) { - console.log("SIGNATURE_ERROR"); - return; - } - const recipient = chatMsg.recipient ?? "Broadcast"; - const msgType: MessageType = chatMsg.type ?? (recipient === "Broadcast" ? "general" : "private"); - if (msgType === "private" && recipient !== myId) { - return; - } - const isForMe = recipient === "Broadcast" || recipient === myId; - if (!isForMe) { - console.log(`ID_MISMATCH: Incoming [${recipient}], Mine [${myId}]`); - return; - } - const isFromMe = chatMsg.peerId === myId; - setMessages((prev) => { - if (prev.some((m) => m.id === chatMsg.id)) return prev; - return [ - ...prev, - { - ...chatMsg, - fromMe: isFromMe, - type: msgType, - }, - ]; - }); - if (!isFromMe) { - setP2pNotification(true); - if (typeof document !== "undefined" && document.hidden) { - setTabUnreadCount((c) => c + 1); - if (typeof window !== "undefined" && window.AudioContext) { - try { - const ctx = new window.AudioContext(); - const o = ctx.createOscillator(); - const g = ctx.createGain(); - o.connect(g); - g.connect(ctx.destination); - o.frequency.value = 880; - o.type = "sine"; - g.gain.setValueAtTime(0.08, ctx.currentTime); - g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08); - o.start(ctx.currentTime); - o.stop(ctx.currentTime + 0.08); - } catch { - // ignore - } - } - } - if (recipient === myId && chatMsg.peerId !== myId) { - setUnreadPeers((prev) => { - if (activeViewRef.current === "direct" && activeDirectPeerIdRef.current === chatMsg.peerId) return prev; - const next = new Set(prev); - next.add(chatMsg.peerId); - return next; - }); - } - } - }); - - return () => { - socket.removeAllListeners(); - socket.disconnect(); - socketRef.current = null; - }; - }, [isInitialized, peerId, displayName, verifySignature]); - - useEffect(() => { - if (typeof document === "undefined") return; - baseTitleRef.current = document.title || "CommonChat"; - }, []); - - useEffect(() => { - if (typeof document === "undefined") return; - document.title = tabUnreadCount > 0 ? `(${tabUnreadCount}) New Message - CommonChat` : baseTitleRef.current; - }, [tabUnreadCount]); - - useEffect(() => { - if (typeof document === "undefined") return; - const onVisibility = () => { - if (!document.hidden) { - setTabUnreadCount(0); - document.title = baseTitleRef.current; - } - }; - document.addEventListener("visibilitychange", onVisibility); - return () => document.removeEventListener("visibilitychange", onVisibility); - }, []); - - useEffect(() => { - if (!p2pNotification) return; - const t = setTimeout(() => setP2pNotification(false), 4000); - return () => clearTimeout(t); - }, [p2pNotification]); - - const openEditName = () => { - setEditNameValue(displayName); - setEditingName(true); - }; - const saveDisplayName = () => { - const v = editNameValue.trim(); - if (v) setDisplayName(v); - setEditingName(false); - }; - - const activeUsersCount = onlinePeers.length + 1; - const showModal = !isInitialized && isReady && !error; - - return ( -
- {/* Full-screen modal when no displayName */} - {showModal && ( -
-
- -
- - setOperatorName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleInitialize()} - placeholder="Your call sign" - className="mt-2 w-full rounded border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" - autoFocus - disabled={modalClosing} - /> -
- -
-
- )} - - {/* Left sidebar — only when initialized */} - {isInitialized && ( - <> - - - {/* Right: chat */} -
-
-
-

- {activeView === "public" ? ( - <> - - {PUBLIC_ROOMS.find((r) => r.id === activePublicRoomId)?.emoji ?? "🌐"} - - {PUBLIC_ROOMS.find((r) => r.id === activePublicRoomId)?.label ?? "General Chat"} - #{activePublicRoomId} - - ) : activeDirectPeerId ? ( - <>Direct: {resolveRecipientLabel(activeDirectPeerId, onlinePeers)} - ) : ( - <>Direct Messages - )} -

- {activeView === "public" && ( - - Active: {activeUsersCount} - - )} -
-

- {activeView === "public" ? "Public channel. Ed25519 signed." : "Private conversation. End-to-end."} -

-
- -
-
- {activeView === "direct" && !activeDirectPeerId && ( -

- Select a conversation from the left. -

- )} - {activeView === "public" && visibleMessages.length === 0 && ( -

- No general messages yet. Type below and press Enter or click Send. -

- )} - {activeView === "direct" && activeDirectPeerId && visibleMessages.length === 0 && ( -

- Henüz mesaj yok, ilk mesajı sen gönder! -

- )} - {visibleMessages.map((m) => ( -
-
- {activeView === "public" && ( -

- {peerIdShort(m.peerId)} -

- )} -
- - {m.displayName} - - {(m.recipient ?? "Broadcast") !== "Broadcast" && ( - - E2E - - )} -
-

- {m.text} -

-

- {formatTime(m.at)} -

-
- Signature -

- {m.signatureHex} -

-
-
-
- ))} -
-
-
- -
-
- {activeView === "public" && ( -

Sending to everyone (General Chat)

- )} - {activeView === "direct" && activeDirectPeerId && ( -
- - To: {resolveRecipientLabel(activeDirectPeerId, onlinePeers)} (E2E) - -
- )} - {activeView === "direct" && !activeDirectPeerId && ( -

Select a conversation to send a message

- )} -
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSignAndSend(); - } - }} - placeholder={ - activeView === "public" - ? "Message everyone… (Enter to send)" - : activeDirectPeerId - ? "Private message… (Enter to send)" - : "Select a conversation first" - } - disabled={activeView === "direct" && !activeDirectPeerId} - className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800/80 px-4 py-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/30 disabled:opacity-50" - /> - -
-
-
-
- - )} - - {/* Loading while WASM/identity check runs */} - {!isReady && !error && ( -
- Loading… -
- )} - {error && ( -
- {error} -
- )} - - {/* New P2P message notification - bottom right */} - {p2pNotification && ( -
-

- New P2P Message Received -

-
- )} - - {/* Built by - minimal signature */} -
-

- Built by{" "} - - Holosko - - {" "}with{" "} - - @commonwarexyz - -

-
-
- ); + const { + displayName, + setDisplayName, + peerId, + signMessage, + verifySignature, + isReady, + isInitialized, + initializeWithDisplayName, + encryptForPeer, + decryptFromPeer, + ed25519PubToX25519Pub, + getX25519PublicKey, + error, + } = useIdentity(); + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [editingName, setEditingName] = useState(false); + const [editNameValue, setEditNameValue] = useState(displayName); + + const [operatorName, setOperatorName] = useState(""); + const [modalClosing, setModalClosing] = useState(false); + const [selectedRecipient, setSelectedRecipient] = useState(""); + const [p2pNotification, setP2pNotification] = useState(false); + const [onlinePeers, setOnlinePeers] = useState< + { id: string; name: string; pubKey: string }[] + >([]); + const [relayConnected, setRelayConnected] = useState(false); + const socketRef = useRef(null); + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + type ViewMode = "public" | "direct"; + const [activeView, setActiveView] = useState("public"); + const [activePublicRoomId, setActivePublicRoomId] = + useState("general"); + const [activeDirectPeerId, setActiveDirectPeerId] = useState( + null, + ); + const [unreadPeers, setUnreadPeers] = useState>(new Set()); + const [tabUnreadCount, setTabUnreadCount] = useState(0); + const baseTitleRef = useRef("CommonChat"); + const activeViewRef = useRef("public"); + const activePublicRoomIdRef = useRef("general"); + const activeDirectPeerIdRef = useRef(null); + useEffect(() => { + activeViewRef.current = activeView; + activePublicRoomIdRef.current = activePublicRoomId; + activeDirectPeerIdRef.current = activeDirectPeerId; + }, [activeView, activePublicRoomId, activeDirectPeerId]); + + const previousPeers = getPreviousPeers(messages, peerId, onlinePeers); + const directMessagesPeerList = getDirectMessagesPeerList( + onlinePeers, + previousPeers, + peerId, + ); + + const isPublicRoomMessage = (m: ChatMessage, roomId: PublicRoomId) => { + const t = + m.type ?? + ((m.recipient ?? "Broadcast") === "Broadcast" ? "general" : "private"); + if (t !== "general" || (m.recipient ?? "Broadcast") !== "Broadcast") + return false; + const msgRoom = (m.roomId ?? "general") as PublicRoomId; + return msgRoom === roomId; + }; + const isPrivateMessageWith = (m: ChatMessage, otherPeerId: string) => { + const t = + m.type ?? + ((m.recipient ?? "Broadcast") === "Broadcast" ? "general" : "private"); + if (t !== "private") return false; + return ( + (m.fromMe && (m.recipient ?? "") === otherPeerId) || + (!m.fromMe && m.peerId === otherPeerId) + ); + }; + const visibleMessages: ChatMessage[] = + activeView === "public" + ? messages.filter((m) => isPublicRoomMessage(m, activePublicRoomId)) + : activeDirectPeerId + ? messages.filter((m) => isPrivateMessageWith(m, activeDirectPeerId)) + : []; + + const openPublicRoom = useCallback((roomId: PublicRoomId) => { + setActiveView("public"); + setActivePublicRoomId(roomId); + setActiveDirectPeerId(null); + setSelectedRecipient(""); + }, []); + const openDirectWith = useCallback((peerPubKey: string) => { + setActiveView("direct"); + setActiveDirectPeerId(peerPubKey); + setSelectedRecipient(peerPubKey); + setUnreadPeers((prev) => { + const next = new Set(prev); + next.delete(peerPubKey); + return next; + }); + }, []); + + const storageKey = peerId ? `${MESSAGES_STORAGE_KEY}_${peerId}` : null; + + useEffect(() => { + if (!storageKey || typeof window === "undefined") return; + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw) as ChatMessage[]; + if (Array.isArray(parsed) && parsed.length > 0) { + setMessages(parsed); + } + } + } catch { + // ignore + } + }, [storageKey]); + + useEffect(() => { + if (!storageKey || messages.length === 0) return; + try { + const toStore = messages.slice(-500); + localStorage.setItem(storageKey, JSON.stringify(toStore)); + } catch { + // quota or parse error + } + }, [storageKey, messages]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [visibleMessages]); + + const handleInitialize = useCallback(() => { + const name = operatorName.trim(); + if (!name) return; + setModalClosing(true); + setTimeout(() => { + initializeWithDisplayName(name); + setModalClosing(false); + }, 220); + }, [operatorName, initializeWithDisplayName]); + + const handleSignAndSend = useCallback(() => { + const text = input.trim(); + if (!text || !isInitialized) return; + + const isPrivate = activeView === "direct" && !!activeDirectPeerId; + const recipient = isPrivate + ? activeDirectPeerId || + resolveRecipientId(selectedRecipient, onlinePeers) || + selectedRecipient.trim() || + "" + : "Broadcast"; + if (isPrivate && !recipient) return; + + // For DMs: convert recipient Ed25519 → X25519, encrypt, sign ciphertext + // For public: sign plaintext directly + let wireText: string; + if (isPrivate) { + const recipientX25519 = ed25519PubToX25519Pub(recipient); + if (!recipientX25519) return; + const cipherText = encryptForPeer(recipientX25519, text); + if (!cipherText) return; + wireText = cipherText; + } else { + wireText = text; + } + + const sig = signMessage(wireText); + if (sig == null) return; + + const msg: ChatMessage = { + id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + text: wireText, + signatureHex: sig, + fromMe: true, + displayName, + peerId, + recipient: recipient || "Broadcast", + at: Date.now(), + type: isPrivate ? "private" : "general", + roomId: activeView === "public" ? activePublicRoomId : undefined, + }; + socketRef.current?.emit("message", msg); + // Store plaintext locally so our own DMs display correctly + setMessages((prev) => [...prev, { ...msg, text }]); + setInput(""); + requestAnimationFrame(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }); + }, [ + input, + isInitialized, + signMessage, + displayName, + peerId, + activeView, + activePublicRoomId, + activeDirectPeerId, + selectedRecipient, + onlinePeers, + encryptForPeer, + ed25519PubToX25519Pub, + ]); + + useEffect(() => { + if (!isInitialized || !peerId || !displayName) return; + const socket = io(RELAY_URL, { autoConnect: true }); + socketRef.current = socket; + + socket.on("connect", () => { + setRelayConnected(true); + socket.emit("register", { peerId, displayName }); + }); + socket.on("disconnect", () => setRelayConnected(false)); + + socket.on( + "online_list", + (list: { id: string; name: string; pubKey: string }[]) => { + setOnlinePeers(Array.isArray(list) ? list : []); + }, + ); + socket.on( + "user_online", + (user: { + peerId?: string; + id?: string; + name?: string; + pubKey?: string; + displayName?: string; + }) => { + const pId = user.peerId ?? user.id ?? user.pubKey; + if (!pId || pId === peerId) return; + const name = user.name ?? user.displayName ?? truncate(pId, 12); + setOnlinePeers((prev) => { + const without = prev.filter((p) => p.pubKey !== pId); + return [...without, { id: pId, name, pubKey: pId }]; + }); + }, + ); + socket.on("user_offline", (data: { peerId: string }) => { + if (data.peerId === peerId) return; + setOnlinePeers((prev) => prev.filter((p) => p.pubKey !== data.peerId)); + }); + + socket.on("message", async (chatMsg: ChatMessage) => { + const myId = peerId; + console.log("Incoming message:", chatMsg); + + if (!chatMsg?.text || !chatMsg?.signatureHex || !chatMsg?.peerId) return; + const valid = await verifySignature( + chatMsg.peerId, + chatMsg.text, + chatMsg.signatureHex, + ); + if (!valid) { + console.log("SIGNATURE_ERROR"); + return; + } + const recipient = chatMsg.recipient ?? "Broadcast"; + const msgType: MessageType = + chatMsg.type ?? (recipient === "Broadcast" ? "general" : "private"); + if (msgType === "private" && recipient !== myId) { + return; + } + const isForMe = recipient === "Broadcast" || recipient === myId; + if (!isForMe) { + console.log(`ID_MISMATCH: Incoming [${recipient}], Mine [${myId}]`); + return; + } + const isFromMe = chatMsg.peerId === myId; + + // Decrypt DMs: convert sender's Ed25519 peerId → X25519, then decrypt + let displayText = chatMsg.text; + if (msgType === "private" && !isFromMe) { + const senderX25519 = ed25519PubToX25519Pub(chatMsg.peerId); + if (senderX25519) { + const plaintext = decryptFromPeer(senderX25519, chatMsg.text); + if (plaintext) { + displayText = plaintext; + } else { + console.log("DECRYPTION_FAILED for msg:", chatMsg.id); + } + } + } + + setMessages((prev) => { + if (prev.some((m) => m.id === chatMsg.id)) return prev; + return [ + ...prev, + { + ...chatMsg, + text: displayText, + fromMe: isFromMe, + type: msgType, + }, + ]; + }); + if (!isFromMe) { + setP2pNotification(true); + if (typeof document !== "undefined" && document.hidden) { + setTabUnreadCount((c) => c + 1); + if (typeof window !== "undefined" && window.AudioContext) { + try { + const ctx = new window.AudioContext(); + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.connect(g); + g.connect(ctx.destination); + o.frequency.value = 880; + o.type = "sine"; + g.gain.setValueAtTime(0.08, ctx.currentTime); + g.gain.exponentialRampToValueAtTime( + 0.001, + ctx.currentTime + 0.08, + ); + o.start(ctx.currentTime); + o.stop(ctx.currentTime + 0.08); + } catch { + // ignore + } + } + } + if (recipient === myId && chatMsg.peerId !== myId) { + setUnreadPeers((prev) => { + if ( + activeViewRef.current === "direct" && + activeDirectPeerIdRef.current === chatMsg.peerId + ) + return prev; + const next = new Set(prev); + next.add(chatMsg.peerId); + return next; + }); + } + } + }); + + return () => { + socket.removeAllListeners(); + socket.disconnect(); + socketRef.current = null; + }; + }, [isInitialized, peerId, displayName, verifySignature, decryptFromPeer, ed25519PubToX25519Pub]); + + useEffect(() => { + if (typeof document === "undefined") return; + baseTitleRef.current = document.title || "CommonChat"; + }, []); + + useEffect(() => { + if (typeof document === "undefined") return; + document.title = + tabUnreadCount > 0 + ? `(${tabUnreadCount}) New Message - CommonChat` + : baseTitleRef.current; + }, [tabUnreadCount]); + + useEffect(() => { + if (typeof document === "undefined") return; + const onVisibility = () => { + if (!document.hidden) { + setTabUnreadCount(0); + document.title = baseTitleRef.current; + } + }; + document.addEventListener("visibilitychange", onVisibility); + return () => document.removeEventListener("visibilitychange", onVisibility); + }, []); + + useEffect(() => { + if (!p2pNotification) return; + const t = setTimeout(() => setP2pNotification(false), 4000); + return () => clearTimeout(t); + }, [p2pNotification]); + + const openEditName = () => { + setEditNameValue(displayName); + setEditingName(true); + }; + const saveDisplayName = () => { + const v = editNameValue.trim(); + if (v) setDisplayName(v); + setEditingName(false); + }; + + const activeUsersCount = onlinePeers.length + 1; + const showModal = !isInitialized && isReady && !error; + + return ( +
+ {/* Full-screen modal when no displayName */} + {showModal && ( +
+
+ +
+ + setOperatorName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleInitialize()} + placeholder="Your call sign" + className="mt-2 w-full rounded border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" + autoFocus + disabled={modalClosing} + /> +
+ +
+
+ )} + + {/* Left sidebar — only when initialized */} + {isInitialized && ( + <> + + + {/* Right: chat */} +
+
+
+

+ {activeView === "public" ? ( + <> + + {PUBLIC_ROOMS.find((r) => r.id === activePublicRoomId) + ?.emoji ?? "🌐"} + + {PUBLIC_ROOMS.find((r) => r.id === activePublicRoomId) + ?.label ?? "General Chat"} + + #{activePublicRoomId} + + + ) : activeDirectPeerId ? ( + <> + Direct:{" "} + + {resolveRecipientLabel(activeDirectPeerId, onlinePeers)} + + + ) : ( + <>Direct Messages + )} +

+ {activeView === "public" && ( + + Active: {activeUsersCount} + + )} +
+

+ {activeView === "public" + ? "Public channel. Ed25519 signed." + : "Private conversation. End-to-end."} +

+
+ +
+
+ {activeView === "direct" && !activeDirectPeerId && ( +

+ Select a conversation from the left. +

+ )} + {activeView === "public" && visibleMessages.length === 0 && ( +

+ No general messages yet. Type below and press Enter or click + Send. +

+ )} + {activeView === "direct" && + activeDirectPeerId && + visibleMessages.length === 0 && ( +

+ Henüz mesaj yok, ilk mesajı sen gönder! +

+ )} + {visibleMessages.map((m) => ( +
+
+ {activeView === "public" && ( +

+ {peerIdShort(m.peerId)} +

+ )} +
+ + {m.displayName} + + {(m.recipient ?? "Broadcast") !== "Broadcast" && ( + + E2E + + )} +
+

+ {m.text} +

+

+ {formatTime(m.at)} +

+
+ + Signature + +

+ {m.signatureHex} +

+
+
+
+ ))} +
+
+
+ +
+
+ {activeView === "public" && ( +

+ Sending to everyone (General Chat) +

+ )} + {activeView === "direct" && activeDirectPeerId && ( +
+ + To:{" "} + {resolveRecipientLabel(activeDirectPeerId, onlinePeers)}{" "} + (E2E) + +
+ )} + {activeView === "direct" && !activeDirectPeerId && ( +

+ Select a conversation to send a message +

+ )} +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSignAndSend(); + } + }} + placeholder={ + activeView === "public" + ? "Message everyone… (Enter to send)" + : activeDirectPeerId + ? "Private message… (Enter to send)" + : "Select a conversation first" + } + disabled={activeView === "direct" && !activeDirectPeerId} + className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800/80 px-4 py-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/30 disabled:opacity-50" + /> + +
+
+
+
+ + )} + + {/* Loading while WASM/identity check runs */} + {!isReady && !error && ( +
+ Loading… +
+ )} + {error && ( +
+ {error} +
+ )} + + {/* New P2P message notification - bottom right */} + {p2pNotification && ( +
+

+ New P2P Message Received +

+
+ )} + + {/* Built by - minimal signature */} +
+

+ Built by{" "} + + Holosko + {" "} + with{" "} + + @commonwarexyz + +

+
+
+ ); } diff --git a/frontend/src/commonware-lib/commonchat_core.d.ts b/frontend/src/commonware-lib/commonchat_core.d.ts index ee5aebf..5f3aa4c 100644 --- a/frontend/src/commonware-lib/commonchat_core.d.ts +++ b/frontend/src/commonware-lib/commonchat_core.d.ts @@ -2,35 +2,43 @@ /* eslint-disable */ /** - * Kimlik: Ed25519 public key + içeride private key (imza için). + * Identity: Ed25519 public key + private key (for signing). */ export class CommonwareIdentity { private constructor(); free(): void; [Symbol.dispose](): void; + decrypt_from_peer(sender_x25519_pub_hex: string, encrypted_hex: string): string; + encrypt_for_peer(recipient_x25519_pub_hex: string, plaintext: string): string; /** - * Private key'i hex string olarak dışa aktarır (localStorage için). + * Exports the private key as a hex string (for localStorage). */ export_private_hex(): string; + get_x25519_public_key(): string; /** - * Mesajı Ed25519 ile imzalar; imza hex string olarak döner. + * Signs the message with Ed25519; returns the signature as a hex string. */ sign(message: string): string; readonly pub_key: string; } /** - * Yeni rastgele kimlik oluşturur. + * Creates a new random identity. */ export function create_identity(): CommonwareIdentity; /** - * localStorage'tan okunan private key hex ile kimliği geri yükler. + * Converts any Ed25519 public key to its X25519 equivalent (Edwards → Montgomery). + */ +export function ed25519_pub_to_x25519_pub(ed25519_pub_hex: string): string; + +/** + * Restores identity from private key hex (e.g. read from localStorage). */ export function identity_from_private_hex(hex_str: string): CommonwareIdentity; /** - * İmzayı doğrular: pub_key_hex ile message üzerindeki signature_hex geçerli mi? + * Verifies the signature: is signature_hex valid for message under pub_key_hex? */ export function verify_signature(pub_key_hex: string, message: string, signature_hex: string): boolean; @@ -39,19 +47,23 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; readonly __wbg_commonwareidentity_free: (a: number, b: number) => void; + readonly commonwareidentity_decrypt_from_peer: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; + readonly commonwareidentity_encrypt_for_peer: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; readonly commonwareidentity_export_private_hex: (a: number) => [number, number]; + readonly commonwareidentity_get_x25519_public_key: (a: number) => [number, number]; readonly commonwareidentity_pub_key: (a: number) => [number, number]; readonly commonwareidentity_sign: (a: number, b: number, c: number) => [number, number]; readonly create_identity: () => number; + readonly ed25519_pub_to_x25519_pub: (a: number, b: number) => [number, number, number, number]; readonly identity_from_private_hex: (a: number, b: number) => [number, number, number]; readonly verify_signature: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; readonly __wbindgen_exn_store: (a: number) => void; readonly __externref_table_alloc: () => number; readonly __wbindgen_externrefs: WebAssembly.Table; - readonly __wbindgen_free: (a: number, b: number, c: number) => void; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; readonly __wbindgen_start: () => void; } diff --git a/frontend/src/commonware-lib/commonchat_core.js b/frontend/src/commonware-lib/commonchat_core.js index b4f24c3..92e8fe8 100644 --- a/frontend/src/commonware-lib/commonchat_core.js +++ b/frontend/src/commonware-lib/commonchat_core.js @@ -1,7 +1,7 @@ /* @ts-self-types="./commonchat_core.d.ts" */ /** - * Kimlik: Ed25519 public key + içeride private key (imza için). + * Identity: Ed25519 public key + private key (for signing). */ export class CommonwareIdentity { static __wrap(ptr) { @@ -22,7 +22,61 @@ export class CommonwareIdentity { wasm.__wbg_commonwareidentity_free(ptr, 0); } /** - * Private key'i hex string olarak dışa aktarır (localStorage için). + * @param {string} sender_x25519_pub_hex + * @param {string} encrypted_hex + * @returns {string} + */ + decrypt_from_peer(sender_x25519_pub_hex, encrypted_hex) { + let deferred4_0; + let deferred4_1; + try { + const ptr0 = passStringToWasm0(sender_x25519_pub_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(encrypted_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.commonwareidentity_decrypt_from_peer(this.__wbg_ptr, ptr0, len0, ptr1, len1); + var ptr3 = ret[0]; + var len3 = ret[1]; + if (ret[3]) { + ptr3 = 0; len3 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred4_0 = ptr3; + deferred4_1 = len3; + return getStringFromWasm0(ptr3, len3); + } finally { + wasm.__wbindgen_free(deferred4_0, deferred4_1, 1); + } + } + /** + * @param {string} recipient_x25519_pub_hex + * @param {string} plaintext + * @returns {string} + */ + encrypt_for_peer(recipient_x25519_pub_hex, plaintext) { + let deferred4_0; + let deferred4_1; + try { + const ptr0 = passStringToWasm0(recipient_x25519_pub_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(plaintext, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.commonwareidentity_encrypt_for_peer(this.__wbg_ptr, ptr0, len0, ptr1, len1); + var ptr3 = ret[0]; + var len3 = ret[1]; + if (ret[3]) { + ptr3 = 0; len3 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred4_0 = ptr3; + deferred4_1 = len3; + return getStringFromWasm0(ptr3, len3); + } finally { + wasm.__wbindgen_free(deferred4_0, deferred4_1, 1); + } + } + /** + * Exports the private key as a hex string (for localStorage). * @returns {string} */ export_private_hex() { @@ -37,6 +91,21 @@ export class CommonwareIdentity { wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); } } + /** + * @returns {string} + */ + get_x25519_public_key() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.commonwareidentity_get_x25519_public_key(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } /** * @returns {string} */ @@ -53,7 +122,7 @@ export class CommonwareIdentity { } } /** - * Mesajı Ed25519 ile imzalar; imza hex string olarak döner. + * Signs the message with Ed25519; returns the signature as a hex string. * @param {string} message * @returns {string} */ @@ -75,7 +144,7 @@ export class CommonwareIdentity { if (Symbol.dispose) CommonwareIdentity.prototype[Symbol.dispose] = CommonwareIdentity.prototype.free; /** - * Yeni rastgele kimlik oluşturur. + * Creates a new random identity. * @returns {CommonwareIdentity} */ export function create_identity() { @@ -84,7 +153,33 @@ export function create_identity() { } /** - * localStorage'tan okunan private key hex ile kimliği geri yükler. + * Converts any Ed25519 public key to its X25519 equivalent (Edwards → Montgomery). + * @param {string} ed25519_pub_hex + * @returns {string} + */ +export function ed25519_pub_to_x25519_pub(ed25519_pub_hex) { + let deferred3_0; + let deferred3_1; + try { + const ptr0 = passStringToWasm0(ed25519_pub_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.ed25519_pub_to_x25519_pub(ptr0, len0); + var ptr2 = ret[0]; + var len2 = ret[1]; + if (ret[3]) { + ptr2 = 0; len2 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred3_0 = ptr2; + deferred3_1 = len2; + return getStringFromWasm0(ptr2, len2); + } finally { + wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); + } +} + +/** + * Restores identity from private key hex (e.g. read from localStorage). * @param {string} hex_str * @returns {CommonwareIdentity} */ @@ -99,7 +194,7 @@ export function identity_from_private_hex(hex_str) { } /** - * İmzayı doğrular: pub_key_hex ile message üzerindeki signature_hex geçerli mi? + * Verifies the signature: is signature_hex valid for message under pub_key_hex? * @param {string} pub_key_hex * @param {string} message * @param {string} signature_hex diff --git a/frontend/src/commonware-lib/commonchat_core_bg.wasm b/frontend/src/commonware-lib/commonchat_core_bg.wasm index b556b17..dbbf421 100644 Binary files a/frontend/src/commonware-lib/commonchat_core_bg.wasm and b/frontend/src/commonware-lib/commonchat_core_bg.wasm differ diff --git a/frontend/src/commonware-lib/commonchat_core_bg.wasm.d.ts b/frontend/src/commonware-lib/commonchat_core_bg.wasm.d.ts index 5349e11..d01bf86 100644 --- a/frontend/src/commonware-lib/commonchat_core_bg.wasm.d.ts +++ b/frontend/src/commonware-lib/commonchat_core_bg.wasm.d.ts @@ -2,17 +2,21 @@ /* eslint-disable */ export const memory: WebAssembly.Memory; export const __wbg_commonwareidentity_free: (a: number, b: number) => void; +export const commonwareidentity_decrypt_from_peer: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; +export const commonwareidentity_encrypt_for_peer: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; export const commonwareidentity_export_private_hex: (a: number) => [number, number]; +export const commonwareidentity_get_x25519_public_key: (a: number) => [number, number]; export const commonwareidentity_pub_key: (a: number) => [number, number]; export const commonwareidentity_sign: (a: number, b: number, c: number) => [number, number]; export const create_identity: () => number; +export const ed25519_pub_to_x25519_pub: (a: number, b: number) => [number, number, number, number]; export const identity_from_private_hex: (a: number, b: number) => [number, number, number]; export const verify_signature: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; export const __wbindgen_exn_store: (a: number) => void; export const __externref_table_alloc: () => number; export const __wbindgen_externrefs: WebAssembly.Table; -export const __wbindgen_free: (a: number, b: number, c: number) => void; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; export const __wbindgen_start: () => void; diff --git a/relay/.nvmrc b/relay/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/relay/.nvmrc @@ -0,0 +1 @@ +22