Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
rustflags = ["--allow", "deprecated"]
14 changes: 14 additions & 0 deletions core/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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"] }
commonware-cryptography = { path = "../commonware-source/cryptography", default-features = false, features = [
"std",
] }
getrandom = { version = "0.2", features = ["js"] }
152 changes: 146 additions & 6 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<String, JsError> {
// 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::<Sha256>::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<String, JsError> {
// 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::<Sha256>::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
}
}

Expand All @@ -58,8 +177,8 @@ pub fn verify_signature(
message: &str,
signature_hex: &str,
) -> Result<bool, JsError> {
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"));
}
Expand All @@ -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<String, JsError> {
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<CommonwareIdentity, JsError> {
Expand Down
1 change: 1 addition & 0 deletions frontend/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
70 changes: 55 additions & 15 deletions frontend/app/hooks/useIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
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<unknown>;
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<CoreModule | null>(null);

const loadStored = useCallback(async () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
}
Loading