diff --git a/src/cmd.rs b/src/cmd.rs index 1fd7dd0..bcfe37e 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -84,6 +84,12 @@ pub enum Cmd { AgeSignName(String, String), // name, message AgeVerifyName(String, String, String), // name, message, signature_b64 AgeList, + + // SYM (symmetric) commands — stateless (Phase 1) + // Raw 32-byte key provided as base64; ciphertext returned as base64 + SymKeygen, + SymEncrypt(String, String), // key_b64, message + SymDecrypt(String, String), // key_b64, ciphertext_b64 } impl Cmd { @@ -623,6 +629,20 @@ impl Cmd { _ => return Err(DBError(format!("unsupported AGE subcommand {:?}", cmd))), } } + "sym" => { + if cmd.len() < 2 { + return Err(DBError("wrong number of arguments for SYM".to_string())); + } + match cmd[1].to_lowercase().as_str() { + "keygen" => { if cmd.len() != 2 { return Err(DBError("SYM KEYGEN takes no args".to_string())); } + Cmd::SymKeygen } + "encrypt" => { if cmd.len() != 4 { return Err(DBError("SYM ENCRYPT ".to_string())); } + Cmd::SymEncrypt(cmd[2].clone(), cmd[3].clone()) } + "decrypt" => { if cmd.len() != 4 { return Err(DBError("SYM DECRYPT ".to_string())); } + Cmd::SymDecrypt(cmd[2].clone(), cmd[3].clone()) } + _ => return Err(DBError(format!("unsupported SYM subcommand {:?}", cmd))), + } + } _ => Cmd::Unknow(cmd[0].clone()), }, protocol, @@ -737,6 +757,12 @@ impl Cmd { Cmd::AgeSignName(name, message) => Ok(crate::age::cmd_age_sign_name(server, &name, &message).await), Cmd::AgeVerifyName(name, message, sig_b64) => Ok(crate::age::cmd_age_verify_name(server, &name, &message, &sig_b64).await), Cmd::AgeList => Ok(crate::age::cmd_age_list(server).await), + + // SYM (symmetric): stateless (Phase 1) + Cmd::SymKeygen => Ok(crate::sym::cmd_sym_keygen().await), + Cmd::SymEncrypt(key_b64, message) => Ok(crate::sym::cmd_sym_encrypt(&key_b64, &message).await), + Cmd::SymDecrypt(key_b64, ct_b64) => Ok(crate::sym::cmd_sym_decrypt(&key_b64, &ct_b64).await), + Cmd::Unknow(s) => Ok(Protocol::err(&format!("ERR unknown command `{}`", s))), } } diff --git a/src/lib.rs b/src/lib.rs index 66a2990..3042d6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod age; // NEW +pub mod sym; pub mod cmd; pub mod crypto; pub mod error; diff --git a/src/sym.rs b/src/sym.rs new file mode 100644 index 0000000..a4e0d5a --- /dev/null +++ b/src/sym.rs @@ -0,0 +1,123 @@ +//! sym.rs — Stateless symmetric encryption (Phase 1) +//! +//! Commands implemented (RESP): +//! - SYM KEYGEN +//! - SYM ENCRYPT +//! - SYM DECRYPT +//! +//! Notes: +//! - Raw key: exactly 32 bytes, provided as Base64 in commands. +//! - Cipher: XChaCha20-Poly1305 (AEAD) without AAD in Phase 1 +//! - Ciphertext binary layout: [version:1][nonce:24][ciphertext||tag] +//! - Encoding for wire I/O: Base64 + +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use chacha20poly1305::{ + aead::{Aead, KeyInit, OsRng}, + XChaCha20Poly1305, XNonce, +}; +use rand::RngCore; + +use crate::protocol::Protocol; + +const VERSION: u8 = 1; +const NONCE_LEN: usize = 24; +const TAG_LEN: usize = 16; + +#[derive(Debug)] +pub enum SymWireError { + InvalidKey, + BadEncoding, + BadFormat, + BadVersion(u8), + Crypto, +} + +impl SymWireError { + fn to_protocol(self) -> Protocol { + match self { + SymWireError::InvalidKey => Protocol::err("ERR sym: invalid key"), + SymWireError::BadEncoding => Protocol::err("ERR sym: bad encoding"), + SymWireError::BadFormat => Protocol::err("ERR sym: bad format"), + SymWireError::BadVersion(v) => Protocol::err(&format!("ERR sym: unsupported version {}", v)), + SymWireError::Crypto => Protocol::err("ERR sym: auth failed"), + } + } +} + +fn decode_key_b64(s: &str) -> Result { + let bytes = B64.decode(s.as_bytes()).map_err(|_| SymWireError::BadEncoding)?; + if bytes.len() != 32 { + return Err(SymWireError::InvalidKey); + } + Ok(chacha20poly1305::Key::from_slice(&bytes).to_owned()) +} + +fn encrypt_blob(key: &chacha20poly1305::Key, plaintext: &[u8]) -> Result, SymWireError> { + let cipher = XChaCha20Poly1305::new(key); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = XNonce::from_slice(&nonce_bytes); + + let mut out = Vec::with_capacity(1 + NONCE_LEN + plaintext.len() + TAG_LEN); + out.push(VERSION); + out.extend_from_slice(&nonce_bytes); + + let ct = cipher.encrypt(nonce, plaintext).map_err(|_| SymWireError::Crypto)?; + out.extend_from_slice(&ct); + Ok(out) +} + +fn decrypt_blob(key: &chacha20poly1305::Key, blob: &[u8]) -> Result, SymWireError> { + if blob.len() < 1 + NONCE_LEN + TAG_LEN { + return Err(SymWireError::BadFormat); + } + let ver = blob[0]; + if ver != VERSION { + return Err(SymWireError::BadVersion(ver)); + } + let nonce = XNonce::from_slice(&blob[1..1 + NONCE_LEN]); + let ct = &blob[1 + NONCE_LEN..]; + + let cipher = XChaCha20Poly1305::new(key); + cipher.decrypt(nonce, ct).map_err(|_| SymWireError::Crypto) +} + +// ---------- Command handlers (RESP) ---------- + +pub async fn cmd_sym_keygen() -> Protocol { + let mut key_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut key_bytes); + let key_b64 = B64.encode(key_bytes); + Protocol::BulkString(key_b64) +} + +pub async fn cmd_sym_encrypt(key_b64: &str, message: &str) -> Protocol { + let key = match decode_key_b64(key_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + match encrypt_blob(&key, message.as_bytes()) { + Ok(blob) => Protocol::BulkString(B64.encode(blob)), + Err(e) => e.to_protocol(), + } +} + +pub async fn cmd_sym_decrypt(key_b64: &str, ct_b64: &str) -> Protocol { + let key = match decode_key_b64(key_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + let blob = match B64.decode(ct_b64.as_bytes()) { + Ok(b) => b, + Err(_) => return SymWireError::BadEncoding.to_protocol(), + }; + match decrypt_blob(&key, &blob) { + Ok(pt) => match String::from_utf8(pt) { + Ok(s) => Protocol::BulkString(s), + Err(_) => Protocol::err("ERR sym: invalid UTF-8 plaintext"), + }, + Err(e) => e.to_protocol(), + } +} \ No newline at end of file