SSE streaming with long-poll fallback; encrypted unicast profile; registry enc keys
This commit is contained in:
+129
@@ -1,9 +1,17 @@
|
||||
use crate::PROTOCOL;
|
||||
use crate::message::Envelope;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use hkdf::Hkdf;
|
||||
use rand::RngCore;
|
||||
use rand::rngs::OsRng;
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey, StaticSecret};
|
||||
|
||||
pub const ENC_ALG: &str = "x25519-hkdf-sha256-chacha20poly1305";
|
||||
|
||||
pub struct Keypair {
|
||||
signing: SigningKey,
|
||||
@@ -136,6 +144,102 @@ pub fn verify_envelope(envelope: &Envelope) -> Result<()> {
|
||||
verify_signature(&envelope.key, &signing_bytes(envelope), &envelope.sig)
|
||||
}
|
||||
|
||||
pub fn generate_enc_keypair() -> (String, String) {
|
||||
let secret = StaticSecret::random_from_rng(OsRng);
|
||||
let public = X25519PublicKey::from(&secret);
|
||||
(
|
||||
hex::encode(secret.to_bytes()),
|
||||
hex::encode(public.to_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn enc_public_from_secret(secret_hex: &str) -> Result<String> {
|
||||
let secret = enc_secret_from_hex(secret_hex)?;
|
||||
Ok(hex::encode(X25519PublicKey::from(&secret).to_bytes()))
|
||||
}
|
||||
|
||||
fn enc_secret_from_hex(secret_hex: &str) -> Result<StaticSecret> {
|
||||
let bytes = hex::decode(secret_hex.trim()).context("enc key is not hex")?;
|
||||
let bytes: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("enc key must be 32 bytes"))?;
|
||||
Ok(StaticSecret::from(bytes))
|
||||
}
|
||||
|
||||
fn enc_public_from_hex(public_hex: &str) -> Result<X25519PublicKey> {
|
||||
let bytes = hex::decode(public_hex.trim()).context("enc public key is not hex")?;
|
||||
let bytes: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("enc public key must be 32 bytes"))?;
|
||||
Ok(X25519PublicKey::from(bytes))
|
||||
}
|
||||
|
||||
pub fn unicast_context(from: &str, recipient_enc_public: &str) -> String {
|
||||
format!("{} unicast {} {}", PROTOCOL, from, recipient_enc_public)
|
||||
}
|
||||
|
||||
pub fn encrypt_unicast(recipient_enc_hex: &str, context: &str, plaintext: &[u8]) -> Result<Value> {
|
||||
let recipient = enc_public_from_hex(recipient_enc_hex)?;
|
||||
let ephemeral = EphemeralSecret::random_from_rng(OsRng);
|
||||
let epk = X25519PublicKey::from(&ephemeral);
|
||||
let shared = ephemeral.diffie_hellman(&recipient);
|
||||
let mut key = [0u8; 32];
|
||||
Hkdf::<Sha256>::new(None, shared.as_bytes())
|
||||
.expand(context.as_bytes(), &mut key)
|
||||
.map_err(|_| anyhow!("hkdf expand failed"))?;
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext)
|
||||
.map_err(|_| anyhow!("encryption failed"))?;
|
||||
let mut payload = nonce_bytes.to_vec();
|
||||
payload.extend_from_slice(&ciphertext);
|
||||
Ok(serde_json::json!({
|
||||
"enc": {
|
||||
"alg": ENC_ALG,
|
||||
"epk": hex::encode(epk.to_bytes()),
|
||||
"ct": hex::encode(payload),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn decrypt_unicast(secret_hex: &str, context: &str, enc: &Value) -> Result<Vec<u8>> {
|
||||
let secret = enc_secret_from_hex(secret_hex)?;
|
||||
let alg = enc
|
||||
.get("alg")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("missing algorithm"))?;
|
||||
if alg != ENC_ALG {
|
||||
return Err(anyhow!("unsupported encryption algorithm"));
|
||||
}
|
||||
let epk = enc
|
||||
.get("epk")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("missing ephemeral key"))?;
|
||||
let epk = enc_public_from_hex(epk)?;
|
||||
let ct = enc
|
||||
.get("ct")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("missing ciphertext"))?;
|
||||
let payload = hex::decode(ct).context("ciphertext is not hex")?;
|
||||
if payload.len() < 12 {
|
||||
return Err(anyhow!("ciphertext too short"));
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = payload.split_at(12);
|
||||
let shared = secret.diffie_hellman(&epk);
|
||||
let mut key = [0u8; 32];
|
||||
Hkdf::<Sha256>::new(None, shared.as_bytes())
|
||||
.expand(context.as_bytes(), &mut key)
|
||||
.map_err(|_| anyhow!("hkdf expand failed"))?;
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
cipher
|
||||
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|_| anyhow!("decryption failed"))
|
||||
}
|
||||
|
||||
pub fn now_ts() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -168,6 +272,31 @@ mod tests {
|
||||
verify_envelope(&env).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicast_encryption_roundtrips_and_rejects_tampering() {
|
||||
let (secret, public) = generate_enc_keypair();
|
||||
let context = unicast_context("alice.example", &public);
|
||||
let plaintext = b"{\"qid\":\"q1\",\"results\":[]}";
|
||||
let envelope_body = encrypt_unicast(&public, &context, plaintext).unwrap();
|
||||
assert_eq!(
|
||||
envelope_body.pointer("/enc/alg").and_then(Value::as_str),
|
||||
Some(ENC_ALG)
|
||||
);
|
||||
let enc = envelope_body.get("enc").unwrap();
|
||||
let decrypted = decrypt_unicast(&secret, &context, enc).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
|
||||
let (other_secret, _) = generate_enc_keypair();
|
||||
assert!(decrypt_unicast(&other_secret, &context, enc).is_err());
|
||||
|
||||
let mut tampered = enc.clone();
|
||||
let ct = tampered.get("ct").and_then(Value::as_str).unwrap();
|
||||
let mut bytes = hex::decode(ct).unwrap();
|
||||
bytes[13] ^= 0xff;
|
||||
tampered["ct"] = json!(hex::encode(bytes));
|
||||
assert!(decrypt_unicast(&secret, &context, &tampered).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_body_fails_verification() {
|
||||
let key = Keypair::generate();
|
||||
|
||||
Reference in New Issue
Block a user