diff --git a/Cargo.lock b/Cargo.lock index c3faa26..2593623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -834,6 +834,7 @@ dependencies = [ "sled", "thiserror 1.0.69", "tokio", + "x25519-dalek", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4ba1e29..f07b6fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ sha2 = "0.10" age = "0.10" secrecy = "0.8" ed25519-dalek = "2" +x25519-dalek = "2" base64 = "0.22" jsonrpsee = { version = "0.26.0", features = ["http-client", "ws-client", "server", "macros"] } diff --git a/src/age.rs b/src/age.rs index f72132c..bae35d0 100644 --- a/src/age.rs +++ b/src/age.rs @@ -20,6 +20,7 @@ use ed25519_dalek::{Signature, Signer, Verifier, SigningKey, VerifyingKey}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use std::collections::HashSet; +use std::convert::TryInto; use crate::protocol::Protocol; use crate::server::Server; @@ -75,6 +76,125 @@ fn parse_ed25519_verifying_key(s: &str) -> Result { VerifyingKey::from_bytes(&key_bytes).map_err(|_| AgeWireError::ParseKey) } +// ---------- Derivation + Raw X25519 (Ed25519 -> X25519) ---------- +// +// We deterministically derive an X25519 keypair from an Ed25519 SigningKey. +// We persist the X25519 public/secret as base64-encoded 32-byte raw values +// (no "age1..."/"AGE-SECRET-KEY-1..." formatting). Name-based encrypt/decrypt +// uses these raw values directly via x25519-dalek + ChaCha20Poly1305. + +use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Key, Nonce}; +use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey as XPublicKey, StaticSecret as XStaticSecret}; + +fn derive_x25519_raw_from_ed25519(sk: &SigningKey) -> ([u8; 32], [u8; 32]) { + // X25519 secret scalar (clamped) from Ed25519 secret + let scalar: [u8; 32] = sk.to_scalar_bytes(); + // Build X25519 secret/public using dalek + let xsec = XStaticSecret::from(scalar); + let xpub = XPublicKey::from(&xsec); + (xpub.to_bytes(), xsec.to_bytes()) +} + +fn derive_x25519_raw_b64_from_ed25519(sk: &SigningKey) -> (String, String) { + let (xpub, xsec) = derive_x25519_raw_from_ed25519(sk); + (B64.encode(xpub), B64.encode(xsec)) +} + +// Helper: detect whether a stored key looks like an age-formatted string +fn looks_like_age_format(s: &str) -> bool { + s.starts_with("age1") || s.starts_with("AGE-SECRET-KEY-1") +} + +// Our container format for name-based raw X25519 encryption: +// bytes = "HDBX1" (5) || eph_pub(32) || nonce(12) || ciphertext(..) +// Entire blob is base64-encoded for transport. +const HDBX1_MAGIC: &[u8; 5] = b"HDBX1"; + +fn encrypt_b64_with_x25519_raw(recip_pub_b64: &str, msg: &str) -> Result { + use rand::RngCore; + use rand::rngs::OsRng; + + // Parse recipient public key (raw 32 bytes, base64) + let recip_pub_bytes = B64.decode(recip_pub_b64).map_err(|_| AgeWireError::ParseKey)?; + if recip_pub_bytes.len() != 32 { return Err(AgeWireError::ParseKey); } + let recip_pub_arr: [u8; 32] = recip_pub_bytes.as_slice().try_into().map_err(|_| AgeWireError::ParseKey)?; + let recip_pub: XPublicKey = XPublicKey::from(recip_pub_arr); + + // Generate ephemeral X25519 keypair + let mut eph_sec_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut eph_sec_bytes); + let eph_sec = XStaticSecret::from(eph_sec_bytes); + let eph_pub = XPublicKey::from(&eph_sec); + + // ECDH + let shared = eph_sec.diffie_hellman(&recip_pub); + // Derive symmetric key via SHA-256 over context + shared + parties + let mut hasher = Sha256::default(); + hasher.update(b"herodb-x25519-v1"); + hasher.update(shared.as_bytes()); + hasher.update(eph_pub.as_bytes()); + hasher.update(recip_pub.as_bytes()); + let key_bytes = hasher.finalize(); + let key = Key::from_slice(&key_bytes[..32]); + + // Nonce (12 bytes) + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt + let cipher = ChaCha20Poly1305::new(key); + let ct = cipher.encrypt(nonce, msg.as_bytes()) + .map_err(|e| AgeWireError::Crypto(format!("encrypt: {e}")))?; + + // Assemble container + let mut out = Vec::with_capacity(5 + 32 + 12 + ct.len()); + out.extend_from_slice(HDBX1_MAGIC); + out.extend_from_slice(eph_pub.as_bytes()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ct); + + Ok(B64.encode(out)) +} + +fn decrypt_b64_with_x25519_raw(identity_sec_b64: &str, ct_b64: &str) -> Result { + // Parse X25519 secret (raw 32 bytes, base64) + let sec_bytes = B64.decode(identity_sec_b64).map_err(|_| AgeWireError::ParseKey)?; + if sec_bytes.len() != 32 { return Err(AgeWireError::ParseKey); } + let sec_arr: [u8; 32] = sec_bytes.as_slice().try_into().map_err(|_| AgeWireError::ParseKey)?; + let xsec = XStaticSecret::from(sec_arr); + let xpub = XPublicKey::from(&xsec); // self public + + // Decode container + let blob = B64.decode(ct_b64.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?; + if blob.len() < 5 + 32 + 12 { return Err(AgeWireError::Crypto("ciphertext too short".to_string())); } + if &blob[..5] != HDBX1_MAGIC { return Err(AgeWireError::Crypto("bad header".to_string())); } + + let eph_pub_arr: [u8; 32] = blob[5..5+32].try_into().map_err(|_| AgeWireError::Crypto("bad eph pub".to_string()))?; + let eph_pub = XPublicKey::from(eph_pub_arr); + let nonce_bytes: [u8; 12] = blob[5+32..5+32+12].try_into().unwrap(); + let ct = &blob[5+32+12..]; + + // Recompute shared + key + let shared = xsec.diffie_hellman(&eph_pub); + let mut hasher = Sha256::default(); + hasher.update(b"herodb-x25519-v1"); + hasher.update(shared.as_bytes()); + hasher.update(eph_pub.as_bytes()); + hasher.update(xpub.as_bytes()); + let key_bytes = hasher.finalize(); + let key = Key::from_slice(&key_bytes[..32]); + + // Decrypt + let cipher = ChaCha20Poly1305::new(key); + let nonce = Nonce::from_slice(&nonce_bytes); + let pt = cipher.decrypt(nonce, ct) + .map_err(|e| AgeWireError::Crypto(format!("decrypt: {e}")))?; + + String::from_utf8(pt).map_err(|_| AgeWireError::Utf8) +} + // ---------- Stateless crypto helpers (string in/out) ---------- pub fn gen_enc_keypair() -> (String, String) { @@ -211,13 +331,66 @@ pub async fn cmd_age_verify(verify_pub: &str, message: &str, sig_b64: &str) -> P } } +// ---------- NEW: unified stateless generator (Ed25519 + derived X25519 raw) ---------- +// +// Returns 4-tuple: +// [ verify_pub_b64 (32B), signpriv_b64 (32B), x25519_pub_b64 (32B), x25519_sec_b64 (32B) ] +// No persistence (stateless). +pub async fn cmd_age_genkey() -> Protocol { + use rand::RngCore; + use rand::rngs::OsRng; + + let mut secret_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut secret_bytes); + + let signing_key = SigningKey::from_bytes(&secret_bytes); + let verifying_key = signing_key.verifying_key(); + + let verify_b64 = B64.encode(verifying_key.to_bytes()); + let sign_b64 = B64.encode(signing_key.to_bytes()); + + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&signing_key); + + Protocol::Array(vec![ + Protocol::BulkString(verify_b64), + Protocol::BulkString(sign_b64), + Protocol::BulkString(xpub_b64), + Protocol::BulkString(xsec_b64), + ]) +} + // ---------- NEW: Persistent, named-key commands ---------- pub async fn cmd_age_keygen(server: &Server, name: &str) -> Protocol { - let (recip, ident) = gen_enc_keypair(); - if let Err(e) = sset(server, &enc_pub_key_key(name), &recip) { return e.to_protocol(); } - if let Err(e) = sset(server, &enc_priv_key_key(name), &ident) { return e.to_protocol(); } - Protocol::Array(vec![Protocol::BulkString(recip), Protocol::BulkString(ident)]) + use rand::RngCore; + use rand::rngs::OsRng; + + // Generate Ed25519 keypair + let mut secret_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut secret_bytes); + let signing_key = SigningKey::from_bytes(&secret_bytes); + let verifying_key = signing_key.verifying_key(); + + // Encode Ed25519 as base64 (32 bytes) + let verify_b64 = B64.encode(verifying_key.to_bytes()); + let sign_b64 = B64.encode(signing_key.to_bytes()); + + // Derive X25519 raw (32-byte) keys and encode as base64 + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&signing_key); + + // Persist Ed25519 and derived X25519 (key-managed mode) + if let Err(e) = sset(server, &sign_pub_key_key(name), &verify_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &sign_priv_key_key(name), &sign_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); } + + // Return unified 4-tuple + Protocol::Array(vec![ + Protocol::BulkString(verify_b64), + Protocol::BulkString(sign_b64), + Protocol::BulkString(xpub_b64), + Protocol::BulkString(xsec_b64), + ]) } pub async fn cmd_age_signkeygen(server: &Server, name: &str) -> Protocol { @@ -228,26 +401,76 @@ pub async fn cmd_age_signkeygen(server: &Server, name: &str) -> Protocol { } pub async fn cmd_age_encrypt_name(server: &Server, name: &str, message: &str) -> Protocol { - let recip = match sget(server, &enc_pub_key_key(name)) { + // Load stored recipient (could be raw b64 32-byte or "age1..." from legacy) + let recip_or_b64 = match sget(server, &enc_pub_key_key(name)) { Ok(Some(v)) => v, - Ok(None) => return AgeWireError::NotFound("recipient (age:key:{name})").to_protocol(), + Ok(None) => { + // Derive from stored Ed25519 if present, then persist + match sget(server, &sign_priv_key_key(name)) { + Ok(Some(sign_b64)) => { + let sk = match parse_ed25519_signing_key(&sign_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&sk); + if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); } + xpub_b64 + } + Ok(None) => return AgeWireError::NotFound("recipient (age:key:{name})").to_protocol(), + Err(e) => return e.to_protocol(), + } + } Err(e) => return e.to_protocol(), }; - match encrypt_b64(&recip, message) { - Ok(ct) => Protocol::BulkString(ct), - Err(e) => e.to_protocol(), + + if looks_like_age_format(&recip_or_b64) { + match encrypt_b64(&recip_or_b64, message) { + Ok(ct) => Protocol::BulkString(ct), + Err(e) => e.to_protocol(), + } + } else { + match encrypt_b64_with_x25519_raw(&recip_or_b64, message) { + Ok(ct) => Protocol::BulkString(ct), + Err(e) => e.to_protocol(), + } } } pub async fn cmd_age_decrypt_name(server: &Server, name: &str, ct_b64: &str) -> Protocol { - let ident = match sget(server, &enc_priv_key_key(name)) { + // Load stored identity (could be raw b64 32-byte or "AGE-SECRET-KEY-1..." from legacy) + let ident_or_b64 = match sget(server, &enc_priv_key_key(name)) { Ok(Some(v)) => v, - Ok(None) => return AgeWireError::NotFound("identity (age:privkey:{name})").to_protocol(), + Ok(None) => { + // Derive from stored Ed25519 if present, then persist + match sget(server, &sign_priv_key_key(name)) { + Ok(Some(sign_b64)) => { + let sk = match parse_ed25519_signing_key(&sign_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&sk); + if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); } + xsec_b64 + } + Ok(None) => return AgeWireError::NotFound("identity (age:privkey:{name})").to_protocol(), + Err(e) => return e.to_protocol(), + } + } Err(e) => return e.to_protocol(), }; - match decrypt_b64(&ident, ct_b64) { - Ok(pt) => Protocol::BulkString(pt), - Err(e) => e.to_protocol(), + + if looks_like_age_format(&ident_or_b64) { + match decrypt_b64(&ident_or_b64, ct_b64) { + Ok(pt) => Protocol::BulkString(pt), + Err(e) => e.to_protocol(), + } + } else { + match decrypt_b64_with_x25519_raw(&ident_or_b64, ct_b64) { + Ok(pt) => Protocol::BulkString(pt), + Err(e) => e.to_protocol(), + } } } diff --git a/src/cmd.rs b/src/cmd.rs index bcfe37e..694f6e1 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -71,6 +71,7 @@ pub enum Cmd { // AGE (rage) commands — stateless AgeGenEnc, AgeGenSign, + AgeGenKey, // unified stateless: returns [verify_b64, signpriv_b64, x25519_pub_b64, x25519_sec_b64] AgeEncrypt(String, String), // recipient, message AgeDecrypt(String, String), // identity, ciphertext_b64 AgeSign(String, String), // signing_secret, message @@ -602,6 +603,8 @@ impl Cmd { Cmd::AgeGenEnc } "gensign" => { if cmd.len() != 2 { return Err(DBError("AGE GENSIGN takes no args".to_string())); } Cmd::AgeGenSign } + "genkey" => { if cmd.len() != 2 { return Err(DBError("AGE GENKEY takes no args".to_string())); } + Cmd::AgeGenKey } "encrypt" => { if cmd.len() != 4 { return Err(DBError("AGE ENCRYPT ".to_string())); } Cmd::AgeEncrypt(cmd[2].clone(), cmd[3].clone()) } "decrypt" => { if cmd.len() != 4 { return Err(DBError("AGE DECRYPT ".to_string())); } @@ -744,6 +747,7 @@ impl Cmd { // AGE (rage): stateless Cmd::AgeGenEnc => Ok(crate::age::cmd_age_genenc().await), Cmd::AgeGenSign => Ok(crate::age::cmd_age_gensign().await), + Cmd::AgeGenKey => Ok(crate::age::cmd_age_genkey().await), Cmd::AgeEncrypt(recipient, message) => Ok(crate::age::cmd_age_encrypt(&recipient, &message).await), Cmd::AgeDecrypt(identity, ct_b64) => Ok(crate::age::cmd_age_decrypt(&identity, &ct_b64).await), Cmd::AgeSign(secret, message) => Ok(crate::age::cmd_age_sign(&secret, &message).await),