First commit in rust

This commit is contained in:
2026-02-19 13:51:07 +08:00
commit c37ca9ba52
73 changed files with 9737 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
[package]
name = "backchannel-common"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
base64 = { workspace = true }
rand = { workspace = true }
ed25519-dalek = { workspace = true }
x25519-dalek = { workspace = true }
chacha20poly1305 = { workspace = true }
hkdf = { workspace = true }
sha2 = { workspace = true }

View File

@@ -0,0 +1,59 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use hkdf::Hkdf;
use rand::RngCore;
use rand::rngs::OsRng;
use sha2::Sha256;
use crate::error::{BackchannelError, Result};
const HKDF_INFO: &[u8] = b"backchannel-dm-v1";
const HKDF_SALT: &[u8] = b"backchannel-salt-v1";
/// Derive a 32-byte ChaCha20-Poly1305 key from a raw X25519 shared secret
/// using HKDF-SHA256. The raw DH output should never be used directly.
pub fn derive_dm_key(raw_secret: &[u8]) -> [u8; 32] {
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), raw_secret);
let mut okm = [0u8; 32];
hk.expand(HKDF_INFO, &mut okm)
.expect("32-byte output always fits HKDF-SHA256");
okm
}
/// Encrypt `plaintext` with a 32-byte key.
///
/// Returns `(ciphertext_b64, nonce_b64)`. A fresh random nonce is generated
/// for every call — callers must transmit both values to the recipient.
pub fn encrypt(key_bytes: &[u8; 32], plaintext: &[u8]) -> Result<(String, String)> {
let key = Key::from_slice(key_bytes);
let cipher = ChaCha20Poly1305::new(key);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| BackchannelError::Crypto("encryption failed".into()))?;
Ok((B64.encode(&ciphertext), B64.encode(nonce_bytes)))
}
/// Decrypt `ciphertext_b64` using `key_bytes` and `nonce_b64`.
pub fn decrypt(key_bytes: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
let key = Key::from_slice(key_bytes);
let cipher = ChaCha20Poly1305::new(key);
let nonce_bytes = B64.decode(nonce_b64)?;
let nonce_arr: [u8; 12] = nonce_bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
let nonce = Nonce::from(nonce_arr);
let ciphertext = B64.decode(ciphertext_b64)?;
let plaintext = cipher.decrypt(&nonce, ciphertext.as_ref())?;
Ok(plaintext)
}

View File

@@ -0,0 +1,37 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use rand::rngs::OsRng;
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::error::{BackchannelError, Result};
/// Generate an X25519 ephemeral keypair for one DM key exchange.
///
/// The `EphemeralSecret` is consumed by `diffie_hellman`, ensuring it cannot
/// be reused. Call `derive_dm_key` (in `aead`) on the resulting shared secret.
pub fn generate_ephemeral() -> (EphemeralSecret, PublicKey) {
let secret = EphemeralSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);
(secret, public)
}
/// Encode an X25519 public key to base64.
pub fn pubkey_to_b64(key: &PublicKey) -> String {
B64.encode(key.as_bytes())
}
/// Decode a base64 string to an X25519 public key.
pub fn b64_to_pubkey(s: &str) -> Result<PublicKey> {
let bytes = B64.decode(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
Ok(PublicKey::from(arr))
}
/// Perform X25519 Diffie-Hellman and return the 32-byte shared secret.
///
/// `secret` is consumed (ephemeral), preventing reuse.
pub fn diffie_hellman(secret: EphemeralSecret, their_pubkey: &PublicKey) -> [u8; 32] {
let shared = secret.diffie_hellman(their_pubkey);
*shared.as_bytes()
}

View File

@@ -0,0 +1,67 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
use rand::rngs::OsRng;
use crate::error::{BackchannelError, Result};
/// Generate a new Ed25519 identity keypair.
pub fn generate_keypair() -> (SigningKey, VerifyingKey) {
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
(signing_key, verifying_key)
}
/// Encode an Ed25519 public key to a base64 string (32 bytes → ~44 chars).
pub fn pubkey_to_b64(key: &VerifyingKey) -> String {
B64.encode(key.as_bytes())
}
/// Decode a base64 string back to an Ed25519 public key.
pub fn b64_to_pubkey(s: &str) -> Result<VerifyingKey> {
let bytes = B64.decode(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
VerifyingKey::from_bytes(&arr).map_err(|e| BackchannelError::Crypto(e.to_string()))
}
/// Encode a signing key (private key bytes) to base64 for keystore persistence.
pub fn signing_key_to_b64(key: &SigningKey) -> String {
B64.encode(key.to_bytes())
}
/// Decode a base64 string back to a signing key.
pub fn b64_to_signing_key(s: &str) -> Result<SigningKey> {
let bytes = B64.decode(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
Ok(SigningKey::from_bytes(&arr))
}
/// Sign a message with an Ed25519 signing key.
pub fn sign(key: &SigningKey, message: &[u8]) -> Signature {
use ed25519_dalek::Signer;
key.sign(message)
}
/// Verify an Ed25519 signature.
pub fn verify(key: &VerifyingKey, message: &[u8], sig: &Signature) -> Result<()> {
use ed25519_dalek::Verifier;
key.verify(message, sig)?;
Ok(())
}
/// Encode a `Signature` to base64.
pub fn sig_to_b64(sig: &Signature) -> String {
B64.encode(sig.to_bytes())
}
/// Decode a base64 string back to a `Signature`.
pub fn b64_to_sig(s: &str) -> Result<Signature> {
let bytes = B64.decode(s)?;
let arr: [u8; 64] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
Ok(Signature::from_bytes(&arr))
}

View File

@@ -0,0 +1,3 @@
pub mod aead;
pub mod ecdh;
pub mod identity;

View File

@@ -0,0 +1,39 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, BackchannelError>;
#[derive(Debug, Error)]
pub enum BackchannelError {
#[error("cryptographic error: {0}")]
Crypto(String),
#[error("base64 decode error: {0}")]
Base64(#[from] base64::DecodeError),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("invalid key length")]
InvalidKeyLength,
#[error("decryption failed: authentication tag mismatch")]
DecryptionFailed,
#[error("signature verification failed")]
InvalidSignature,
#[error("{0}")]
Other(String),
}
impl From<chacha20poly1305::Error> for BackchannelError {
fn from(_: chacha20poly1305::Error) -> Self {
BackchannelError::DecryptionFailed
}
}
impl From<ed25519_dalek::SignatureError> for BackchannelError {
fn from(_: ed25519_dalek::SignatureError) -> Self {
BackchannelError::InvalidSignature
}
}

View File

@@ -0,0 +1,6 @@
pub mod crypto;
pub mod error;
pub mod protocol;
pub mod types;
pub use error::{BackchannelError, Result};

View File

@@ -0,0 +1,121 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Every message the client can send to the server over the WebSocket.
///
/// Serialised as JSON with an inline `"type"` discriminant, e.g.:
/// `{"type":"login","username":"alice","password":"...","identity_pubkey":"..."}`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
// ── Authentication ────────────────────────────────────────────────────
/// First-time authentication with credentials.
Login {
username: String,
/// Sent in plaintext; the TLS layer protects this in transit.
password: String,
/// Base64-encoded Ed25519 public key (32 bytes).
/// Stored/updated server-side as the user's cryptographic identity.
identity_pubkey: String,
},
/// Register a new account.
Register {
username: String,
password: String,
identity_pubkey: String,
},
/// Re-establish an authenticated session using a previously issued JWT.
/// Avoids re-entering the password on reconnect.
ResumeSession {
token: String,
},
/// Gracefully end the session; server invalidates the JWT.
Logout,
// ── Channel messaging ─────────────────────────────────────────────────
/// Post a plaintext message to a channel.
SendChannelMessage {
channel_id: Uuid,
content: String,
},
/// Request recent message history for a channel.
FetchChannelHistory {
channel_id: Uuid,
/// Pagination: fetch messages older than this ID.
before_message_id: Option<Uuid>,
limit: u32,
},
// ── Direct messages (E2EE) ────────────────────────────────────────────
/// Phase 1 of DM key exchange: initiator sends their X25519 ephemeral
/// public key to the server, addressed to the target recipient.
InitDmKeyExchange {
recipient_id: Uuid,
/// Base64-encoded X25519 ephemeral public key (32 bytes).
sender_ephemeral_pubkey: String,
},
/// Phase 2 of DM key exchange: recipient responds with their ephemeral
/// public key. Both sides can now derive the same shared secret.
AcceptDmKeyExchange {
initiator_id: Uuid,
/// Base64-encoded X25519 ephemeral public key (32 bytes).
recipient_ephemeral_pubkey: String,
},
/// Send an E2EE DM. The server relays the ciphertext without decrypting.
SendDm {
recipient_id: Uuid,
/// Base64-encoded ChaCha20-Poly1305 ciphertext.
ciphertext: String,
/// Base64-encoded nonce (12 bytes).
nonce: String,
},
/// Request DM history with a specific peer.
FetchDmHistory {
peer_id: Uuid,
before_message_id: Option<Uuid>,
limit: u32,
},
// ── Channel management ────────────────────────────────────────────────
CreateChannel {
name: String,
topic: Option<String>,
},
DeleteChannel {
channel_id: Uuid,
},
// ── Role & permission management ──────────────────────────────────────
CreateRole {
name: String,
/// Permission bitmask; see `PermissionFlags` in `types.rs`.
permissions: u64,
},
AssignRole {
user_id: Uuid,
role_id: Uuid,
},
RevokeRole {
user_id: Uuid,
role_id: Uuid,
},
// ── Key directory ─────────────────────────────────────────────────────
/// Look up another user's registered Ed25519 identity public key.
QueryIdentityKey {
user_id: Uuid,
},
// ── Keepalive ─────────────────────────────────────────────────────────
Ping,
}

View File

@@ -0,0 +1,5 @@
pub mod client;
pub mod server;
pub use client::ClientMessage;
pub use server::{HistoricChannelMessage, HistoricDm, ServerMessage};

View File

@@ -0,0 +1,141 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Every message the server can push to a client over the WebSocket.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
// ── Auth responses ────────────────────────────────────────────────────
AuthSuccess {
user_id: Uuid,
username: String,
/// Signed JWT. Client stores this for reconnect via `ResumeSession`.
session_token: String,
},
AuthError {
reason: String,
},
// ── Channel events ────────────────────────────────────────────────────
/// Broadcast to all connected clients when a channel message is posted.
ChannelMessage {
message_id: Uuid,
channel_id: Uuid,
author_id: Uuid,
author_username: String,
content: String,
timestamp: DateTime<Utc>,
},
/// Response to `FetchChannelHistory`.
ChannelHistory {
channel_id: Uuid,
messages: Vec<HistoricChannelMessage>,
},
ChannelCreated {
channel_id: Uuid,
name: String,
topic: Option<String>,
},
ChannelDeleted {
channel_id: Uuid,
},
// ── DM key exchange relay ─────────────────────────────────────────────
/// Server relays the initiator's ephemeral pubkey to the recipient.
DmKeyExchangeRequest {
initiator_id: Uuid,
initiator_username: String,
sender_ephemeral_pubkey: String,
},
/// Server relays the recipient's ephemeral pubkey back to the initiator.
DmKeyExchangeResponse {
recipient_id: Uuid,
recipient_username: String,
recipient_ephemeral_pubkey: String,
},
// ── DM messages ───────────────────────────────────────────────────────
/// Relayed E2EE DM. Server cannot read `ciphertext`.
DirectMessage {
message_id: Uuid,
sender_id: Uuid,
sender_username: String,
ciphertext: String,
nonce: String,
timestamp: DateTime<Utc>,
},
DmHistory {
peer_id: Uuid,
messages: Vec<HistoricDm>,
},
// ── Presence ──────────────────────────────────────────────────────────
Pong,
UserOnline {
user_id: Uuid,
username: String,
},
UserOffline {
user_id: Uuid,
username: String,
},
// ── Role events ───────────────────────────────────────────────────────
RoleCreated {
role_id: Uuid,
name: String,
permissions: u64,
},
RoleAssigned {
user_id: Uuid,
role_id: Uuid,
},
RoleRevoked {
user_id: Uuid,
role_id: Uuid,
},
// ── Key directory ─────────────────────────────────────────────────────
IdentityKeyResponse {
user_id: Uuid,
/// Base64-encoded Ed25519 public key, or null if unknown.
identity_pubkey: Option<String>,
},
// ── Generic error ─────────────────────────────────────────────────────
Error {
code: u16,
message: String,
},
}
/// A channel message returned in history responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoricChannelMessage {
pub message_id: Uuid,
pub author_id: Uuid,
pub author_username: String,
pub content: String,
pub timestamp: DateTime<Utc>,
}
/// A DM returned in history responses. Ciphertext is opaque to the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoricDm {
pub message_id: Uuid,
pub sender_id: Uuid,
pub ciphertext: String,
pub nonce: String,
pub timestamp: DateTime<Utc>,
}

View File

@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// Typed UUID wrappers to prevent accidental mixing of IDs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UserId(pub Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChannelId(pub Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MessageId(pub Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RoleId(pub Uuid);
impl UserId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
impl ChannelId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
impl MessageId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
impl RoleId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
/// Permission bit-flags for server roles.
/// The `ADMINISTRATOR` flag bypasses all individual permission checks.
pub struct PermissionFlags;
impl PermissionFlags {
pub const READ_MESSAGES: u64 = 1 << 0;
pub const SEND_MESSAGES: u64 = 1 << 1;
pub const MANAGE_CHANNELS: u64 = 1 << 2;
pub const MANAGE_ROLES: u64 = 1 << 3;
pub const KICK_MEMBERS: u64 = 1 << 4;
pub const BAN_MEMBERS: u64 = 1 << 5;
/// Bypasses all permission checks.
pub const ADMINISTRATOR: u64 = 1 << 63;
}
/// Check whether `perms` satisfies a required flag, honoring ADMINISTRATOR.
pub fn has_permission(perms: u64, required: u64) -> bool {
(perms & PermissionFlags::ADMINISTRATOR) != 0 || (perms & required) == required
}