SSE streaming with long-poll fallback; encrypted unicast profile; registry enc keys

This commit is contained in:
George Coles
2026-09-15 06:58:35 -04:00
parent 2c97fd523f
commit ec98275613
15 changed files with 1117 additions and 320 deletions
+129
View File
@@ -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();