use chacha20::ChaCha20;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde_json::{Value, json};
use sha2::{Digest, Sha256, Sha512};
use thiserror::Error;
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
use crate::signing::{b64decode, b64encode};
pub const ENC_DISCRIMINATOR: &str = "wire-x25519.v1";
const HKDF_SALT: &[u8] = b"wire-x25519-v1";
const VERSION: u8 = 0x02;
const MAX_PLAINTEXT: usize = 65535;
const MIN_RAW: usize = 1 + 32 + 34 + 32; const MAX_RAW: usize = 1 + 32 + (2 + 65536) + 32;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum EncError {
#[error("x25519 produced an all-zero shared secret (low-order/contributory point)")]
ZeroSharedSecret,
#[error("plaintext length {0} out of range 1..=65535")]
BadLength(usize),
#[error("base64 decode failed")]
BadBase64,
#[error("payload length out of bounds")]
BadPayloadLen,
#[error("unsupported version")]
BadVersion,
#[error("mac verification failed")]
MacFail,
#[error("invalid padding")]
BadPadding,
#[error("plaintext is not valid utf-8")]
BadUtf8,
}
pub fn x25519_scalar_from_ed25519_seed(seed: &[u8; 32]) -> [u8; 32] {
let h = Sha512::digest(seed);
let mut s = [0u8; 32];
s.copy_from_slice(&h[0..32]);
s[0] &= 248;
s[31] &= 127;
s[31] |= 64;
s
}
pub fn x25519_pub_from_ed25519_seed(seed: &[u8; 32]) -> [u8; 32] {
let secret = StaticSecret::from(x25519_scalar_from_ed25519_seed(seed));
PublicKey::from(&secret).to_bytes()
}
pub fn derive_conversation_key(
our_scalar: &[u8; 32],
peer_pub: &[u8; 32],
) -> Result<[u8; 32], EncError> {
let secret = StaticSecret::from(*our_scalar);
let peer = PublicKey::from(*peer_pub);
let shared = secret.diffie_hellman(&peer);
let shared_bytes = shared.to_bytes();
if shared_bytes == [0u8; 32] {
return Err(EncError::ZeroSharedSecret);
}
let (prk, _hk) = Hkdf::<Sha256>::extract(Some(HKDF_SALT), &shared_bytes);
let mut ck = [0u8; 32];
ck.copy_from_slice(&prk);
Ok(ck)
}
fn context_info(nonce: &[u8; 32], from: &str, to: &str) -> Vec<u8> {
debug_assert!(
from.len() <= u16::MAX as usize && to.len() <= u16::MAX as usize,
"identity too long for u16 length-prefix framing"
);
let mut v = Vec::with_capacity(32 + 2 + from.len() + 2 + to.len());
v.extend_from_slice(nonce);
v.extend_from_slice(&(from.len() as u16).to_be_bytes());
v.extend_from_slice(from.as_bytes());
v.extend_from_slice(&(to.len() as u16).to_be_bytes());
v.extend_from_slice(to.as_bytes());
v
}
fn message_keys(conversation_key: &[u8; 32], info: &[u8]) -> ([u8; 32], [u8; 12], [u8; 32]) {
let hk = Hkdf::<Sha256>::from_prk(conversation_key).expect("32-byte prk is valid");
let mut okm = [0u8; 76];
hk.expand(info, &mut okm).expect("76 < 255*32");
let mut chacha_key = [0u8; 32];
chacha_key.copy_from_slice(&okm[0..32]);
let mut chacha_nonce = [0u8; 12];
chacha_nonce.copy_from_slice(&okm[32..44]);
let mut hmac_key = [0u8; 32];
hmac_key.copy_from_slice(&okm[44..76]);
(chacha_key, chacha_nonce, hmac_key)
}
fn calc_padded_len(unpadded: usize) -> usize {
if unpadded <= 32 {
return 32;
}
let l = unpadded as u32;
let next_power = 1usize << (32 - (l - 1).leading_zeros());
let chunk = if next_power <= 256 {
32
} else {
next_power / 8
};
chunk * (((unpadded - 1) / chunk) + 1)
}
fn pad(pt: &[u8]) -> Result<Vec<u8>, EncError> {
let l = pt.len();
if !(1..=MAX_PLAINTEXT).contains(&l) {
return Err(EncError::BadLength(l));
}
let total = 2 + calc_padded_len(l);
let mut buf = Vec::with_capacity(total);
buf.extend_from_slice(&(l as u16).to_be_bytes());
buf.extend_from_slice(pt);
buf.resize(total, 0);
Ok(buf)
}
fn unpad(buf: &[u8]) -> Result<Vec<u8>, EncError> {
if buf.len() < 2 {
return Err(EncError::BadPadding);
}
let l = u16::from_be_bytes([buf[0], buf[1]]) as usize;
let end = 2usize.checked_add(l).ok_or(EncError::BadPadding)?;
if l == 0 || buf.len() < end {
return Err(EncError::BadPadding);
}
let out = &buf[2..end];
if out.len() != l || buf.len() != 2 + calc_padded_len(l) {
return Err(EncError::BadPadding);
}
Ok(out.to_vec())
}
pub(crate) fn seal(
conversation_key: &[u8; 32],
plaintext: &[u8],
from: &str,
to: &str,
) -> Result<String, EncError> {
let mut nonce = [0u8; 32];
rand::thread_rng().fill_bytes(&mut nonce);
let (chacha_key, chacha_nonce, hmac_key) =
message_keys(conversation_key, &context_info(&nonce, from, to));
let mut ct = pad(plaintext)?;
let mut cipher = ChaCha20::new(
chacha20::Key::from_slice(&chacha_key),
chacha20::Nonce::from_slice(&chacha_nonce),
);
cipher.apply_keystream(&mut ct);
let mut mac = HmacSha256::new_from_slice(&hmac_key).expect("hmac accepts any key length");
mac.update(&nonce);
mac.update(&ct);
let tag = mac.finalize().into_bytes();
let mut payload = Vec::with_capacity(1 + 32 + ct.len() + 32);
payload.push(VERSION);
payload.extend_from_slice(&nonce);
payload.extend_from_slice(&ct);
payload.extend_from_slice(&tag);
Ok(b64encode(&payload))
}
pub(crate) fn open(
conversation_key: &[u8; 32],
payload_b64: &str,
from: &str,
to: &str,
) -> Result<String, EncError> {
if payload_b64.as_bytes().first() == Some(&b'#') {
return Err(EncError::BadVersion);
}
if payload_b64.len() > MAX_RAW * 4 / 3 + 4 {
return Err(EncError::BadPayloadLen);
}
let raw = b64decode(payload_b64).map_err(|_| EncError::BadBase64)?;
if !(MIN_RAW..=MAX_RAW).contains(&raw.len()) {
return Err(EncError::BadPayloadLen);
}
if raw[0] != VERSION {
return Err(EncError::BadVersion);
}
let mut nonce = [0u8; 32];
nonce.copy_from_slice(&raw[1..33]);
let mac_start = raw.len() - 32;
let ct = &raw[33..mac_start];
let tag = &raw[mac_start..];
let (chacha_key, chacha_nonce, hmac_key) =
message_keys(conversation_key, &context_info(&nonce, from, to));
let mut mac = HmacSha256::new_from_slice(&hmac_key).expect("hmac accepts any key length");
mac.update(&nonce);
mac.update(ct);
mac.verify_slice(tag).map_err(|_| EncError::MacFail)?;
let mut buf = ct.to_vec();
let mut cipher = ChaCha20::new(
chacha20::Key::from_slice(&chacha_key),
chacha20::Nonce::from_slice(&chacha_nonce),
);
cipher.apply_keystream(&mut buf);
let out = unpad(&buf)?;
String::from_utf8(out).map_err(|_| EncError::BadUtf8)
}
pub fn self_dh_pubkey_b64(seed: &[u8; 32]) -> String {
b64encode(&x25519_pub_from_ed25519_seed(seed))
}
fn decode_dh(b64: &str) -> Option<[u8; 32]> {
let v = b64decode(b64).ok()?;
let arr: [u8; 32] = v.try_into().ok()?;
Some(arr)
}
pub fn peer_dh_pubkey(trust: &Value, peer_did_or_handle: &str) -> Option<[u8; 32]> {
let handle = crate::agent_card::display_handle_from_did(peer_did_or_handle);
let b64 = trust
.get("agents")?
.get(handle)?
.get("card")?
.get("dh_pubkey")?
.as_str()?;
decode_dh(b64)
}
pub fn seal_event_body(
event: &mut Value,
peer_dh_pubkey: &[u8; 32],
our_seed: &[u8; 32],
) -> anyhow::Result<()> {
let from = event
.get("from")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("event missing `from`"))?
.to_string();
let to = event
.get("to")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("encryption requires a `to` recipient on the event"))?
.to_string();
let our_scalar = Zeroizing::new(x25519_scalar_from_ed25519_seed(our_seed));
let ck = Zeroizing::new(
derive_conversation_key(&our_scalar, peer_dh_pubkey)
.map_err(|e| anyhow::anyhow!("derive conversation key: {e}"))?,
);
let pt = serde_json::to_vec(event.get("body").unwrap_or(&Value::Null))?;
let ct = seal(&ck, &pt, &from, &to).map_err(|e| anyhow::anyhow!("seal: {e}"))?;
let obj = event
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("event is not a JSON object"))?;
obj.insert("enc".into(), json!(ENC_DISCRIMINATOR));
obj.insert("body".into(), json!({ "ct": ct }));
Ok(())
}
pub fn open_event_body(
event: &Value,
trust: &Value,
our_seed: &[u8; 32],
) -> anyhow::Result<Option<Value>> {
match event.get("enc").and_then(Value::as_str) {
Some(ENC_DISCRIMINATOR) => {}
Some(_) | None => return Ok(None),
}
crate::signing::verify_message_v31(event, trust)
.map_err(|e| anyhow::anyhow!("refusing to decrypt unverified event: {e}"))?;
let from = event
.get("from")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("event missing `from`"))?;
let to = event
.get("to")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("encrypted event missing `to`"))?;
let ct = event
.get("body")
.and_then(|b| b.get("ct"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("enc event body missing `ct`"))?;
let peer_dh = peer_dh_pubkey(trust, from)
.ok_or_else(|| anyhow::anyhow!("sender has no pinned dh_pubkey — cannot decrypt"))?;
let our_scalar = Zeroizing::new(x25519_scalar_from_ed25519_seed(our_seed));
let ck = Zeroizing::new(
derive_conversation_key(&our_scalar, &peer_dh)
.map_err(|e| anyhow::anyhow!("derive conversation key: {e}"))?,
);
let pt = open(&ck, ct, from, to).map_err(|_| anyhow::anyhow!("decryption failed"))?;
let body: Value = serde_json::from_str(&pt)
.map_err(|_| anyhow::anyhow!("decrypted body is not valid json"))?;
Ok(Some(body))
}
pub fn self_seed_for_read() -> Option<[u8; 32]> {
crate::config::read_private_key()
.ok()
.and_then(|v| v.get(..32).and_then(|s| <[u8; 32]>::try_from(s).ok()))
}
pub fn decrypt_event_for_read(event: &Value, trust: &Value, seed: &[u8; 32]) -> Value {
if event.get("enc").and_then(Value::as_str) == Some(ENC_DISCRIMINATOR)
&& let Ok(Some(plain)) = open_event_body(event, trust, seed)
{
let mut e = event.clone();
if let Some(obj) = e.as_object_mut() {
obj.insert("body".into(), plain);
obj.insert("dec".into(), json!(true));
}
return e;
}
event.clone()
}
#[cfg(test)]
mod tests {
use super::*;
const SEED_A: [u8; 32] = [1u8; 32];
const SEED_B: [u8; 32] = [2u8; 32];
fn conv(seed_self: &[u8; 32], seed_peer: &[u8; 32]) -> [u8; 32] {
let our = x25519_scalar_from_ed25519_seed(seed_self);
let peer_pub = x25519_pub_from_ed25519_seed(seed_peer);
derive_conversation_key(&our, &peer_pub).unwrap()
}
fn hex_to_32(h: &str) -> [u8; 32] {
let v = hex::decode(h).expect("valid hex");
let mut a = [0u8; 32];
a.copy_from_slice(&v);
a
}
#[test]
fn round_trips_with_production_did_identity_form() {
let ck = conv(&SEED_A, &SEED_B);
let from = "did:wire:alice-1b1b58dd";
let to = "did:wire:bob-60346e7c";
let payload = seal(&ck, b"production-form message", from, to).unwrap();
assert_eq!(
open(&ck, &payload, from, to).unwrap(),
"production-form message"
);
assert_eq!(
open(&ck, &payload, "alice", to).unwrap_err(),
EncError::MacFail
);
}
#[test]
fn oversized_input_rejected_without_large_alloc() {
let ck = conv(&SEED_A, &SEED_B);
let bomb = "A".repeat(10_000_000);
assert_eq!(
open(&ck, &bomb, "a", "b").unwrap_err(),
EncError::BadPayloadLen
);
}
#[test]
fn truncated_payload_rejected() {
let ck = conv(&SEED_A, &SEED_B);
let payload = seal(&ck, b"hi", "a", "b").unwrap();
let raw = b64decode(&payload).unwrap();
let truncated = b64encode(&raw[..raw.len() - 40]); assert_eq!(
open(&ck, &truncated, "a", "b").unwrap_err(),
EncError::BadPayloadLen
);
}
#[test]
fn zero_shared_secret_is_rejected() {
let our = x25519_scalar_from_ed25519_seed(&SEED_A);
assert_eq!(
derive_conversation_key(&our, &[0u8; 32]).unwrap_err(),
EncError::ZeroSharedSecret
);
}
#[test]
fn decode_bomb_cap_boundary() {
let ck = conv(&SEED_A, &SEED_B);
let over = "A".repeat(MAX_RAW * 4 / 3 + 5);
assert_eq!(
open(&ck, &over, "a", "b").unwrap_err(),
EncError::BadPayloadLen
);
let big = vec![b'z'; 60000];
let payload = seal(&ck, &big, "a", "b").unwrap();
assert!(
payload.len() < MAX_RAW * 4 / 3 + 5,
"real payload is under the cap"
);
assert_eq!(open(&ck, &payload, "a", "b").unwrap().len(), 60000);
}
#[test]
fn calc_padded_len_conformance_nip44_vectors() {
let nip44: &[(usize, usize)] = &[
(1, 32),
(16, 32),
(32, 32),
(33, 64),
(37, 64),
(45, 64),
(49, 64),
(64, 64),
(65, 96),
(100, 128),
(111, 128),
(200, 224),
(250, 256),
(320, 320),
(383, 384),
(384, 384),
(400, 448),
(500, 512),
(512, 512),
(515, 640),
(700, 768),
(800, 896),
(900, 1024),
(1020, 1024),
(65536, 65536),
];
for &(unpadded, padded) in nip44 {
assert_eq!(
calc_padded_len(unpadded),
padded,
"calc_padded_len({unpadded})"
);
}
}
#[test]
fn message_keys_conformance_nip44_vector() {
let conversation_key =
hex_to_32("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54");
let nonce = hex_to_32("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72");
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&conversation_key, &nonce);
assert_eq!(
hex::encode(chacha_key),
"f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76"
);
assert_eq!(hex::encode(chacha_nonce), "c4ad129bb01180c0933a160c");
assert_eq!(
hex::encode(hmac_key),
"027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
);
}
#[test]
fn conversation_key_is_symmetric() {
assert_eq!(conv(&SEED_A, &SEED_B), conv(&SEED_B, &SEED_A));
}
#[test]
fn derivation_is_deterministic() {
assert_eq!(
x25519_pub_from_ed25519_seed(&SEED_A),
x25519_pub_from_ed25519_seed(&SEED_A)
);
}
#[test]
fn golden_seed_to_pub_and_conv_key() {
let pub_a = x25519_pub_from_ed25519_seed(&SEED_A);
let pub_b = x25519_pub_from_ed25519_seed(&SEED_B);
assert_eq!(hex::encode(pub_a), GOLDEN_PUB_A);
assert_eq!(hex::encode(pub_b), GOLDEN_PUB_B);
assert_eq!(hex::encode(conv(&SEED_A, &SEED_B)), GOLDEN_CONV_AB);
}
#[test]
fn round_trip_across_lengths() {
let ck = conv(&SEED_A, &SEED_B);
for &len in &[1usize, 31, 32, 33, 256, 257, 1000, 65535] {
let pt = "x".repeat(len);
let payload = seal(&ck, pt.as_bytes(), "alice", "bob").unwrap();
let got = open(&ck, &payload, "alice", "bob").unwrap();
assert_eq!(got, pt, "round-trip failed at len {len}");
}
}
#[test]
fn direction_binding_rejects_reflection() {
let ck = conv(&SEED_A, &SEED_B);
let payload = seal(&ck, b"secret", "alice", "bob").unwrap();
assert_eq!(
open(&ck, &payload, "bob", "alice").unwrap_err(),
EncError::MacFail
);
}
#[test]
fn tamper_is_rejected_before_decrypt() {
let ck = conv(&SEED_A, &SEED_B);
let payload = seal(&ck, b"hello world", "alice", "bob").unwrap();
let raw = b64decode(&payload).unwrap();
let mut t = raw.clone();
t[40] ^= 0x01;
assert_eq!(
open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
EncError::MacFail
);
let mut t = raw.clone();
t[1] ^= 0x01;
assert_eq!(
open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
EncError::MacFail
);
let n = raw.len();
let mut t = raw.clone();
t[n - 1] ^= 0x01;
assert_eq!(
open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
EncError::MacFail
);
let mut t = raw.clone();
t[0] = 0x01;
assert_eq!(
open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
EncError::BadVersion
);
}
#[test]
fn plaintext_bounds_enforced() {
let ck = conv(&SEED_A, &SEED_B);
assert_eq!(
seal(&ck, b"", "a", "b").unwrap_err(),
EncError::BadLength(0)
);
let too_big = vec![0u8; 65536];
assert_eq!(
seal(&ck, &too_big, "a", "b").unwrap_err(),
EncError::BadLength(65536)
);
}
#[test]
fn wrong_conversation_key_fails() {
let ck = conv(&SEED_A, &SEED_B);
let payload = seal(&ck, b"secret", "alice", "bob").unwrap();
let other = x25519_scalar_from_ed25519_seed(&[9u8; 32]);
let wrong =
derive_conversation_key(&other, &x25519_pub_from_ed25519_seed(&SEED_B)).unwrap();
assert_eq!(
open(&wrong, &payload, "alice", "bob").unwrap_err(),
EncError::MacFail
);
}
#[test]
fn event_level_round_trip_and_verify_gate() {
use crate::signing::{generate_keypair, make_key_id, sign_message_v31};
let (a_seed, a_pk) = generate_keypair();
let (b_seed, b_pk) = generate_keypair();
let a_did = "did:wire:alice-1b1b58dd";
let b_did = "did:wire:bob-60346e7c";
let b_dh = x25519_pub_from_ed25519_seed(&b_seed);
let a_dh = x25519_pub_from_ed25519_seed(&a_seed);
let _ = &b_pk;
let trust_b = json!({"agents": {"alice": {
"public_keys": [{"key_id": make_key_id("alice", &a_pk), "key": b64encode(&a_pk), "active": true}],
"card": {"dh_pubkey": b64encode(&a_dh)},
}}});
let mut event = json!({
"from": a_did, "to": b_did, "type": "decision", "kind": 1000,
"body": "secret hello",
});
seal_event_body(&mut event, &b_dh, &a_seed).unwrap();
assert_eq!(event["enc"], json!(ENC_DISCRIMINATOR));
assert!(
event["body"]["ct"].is_string(),
"body replaced with ciphertext"
);
assert_ne!(event["body"], json!("secret hello"));
let signed = sign_message_v31(&event, &a_seed, &a_pk, "alice").unwrap();
assert_eq!(
open_event_body(&signed, &trust_b, &b_seed).unwrap(),
Some(json!("secret hello"))
);
let mut tampered = signed.clone();
tampered["body"]["ct"] = json!("AAAAAAAA");
assert!(open_event_body(&tampered, &trust_b, &b_seed).is_err());
let plain = json!({"from": a_did, "to": b_did, "body": "hi"});
assert_eq!(open_event_body(&plain, &trust_b, &b_seed).unwrap(), None);
}
#[test]
fn full_card_pin_seal_read_pipeline() {
use crate::agent_card::{build_agent_card, card_dh_pubkey, sign_agent_card};
use crate::signing::{generate_keypair, sign_message_v31};
use crate::trust::{add_agent_card_pin, empty_trust};
let (a_seed, a_pk) = generate_keypair();
let (b_seed, b_pk) = generate_keypair();
let a_card = sign_agent_card(&build_agent_card("alice", &a_pk, None, None, None), &a_seed);
let _b_card = sign_agent_card(&build_agent_card("bob", &b_pk, None, None, None), &b_seed);
assert_eq!(
card_dh_pubkey(&a_card).unwrap(),
b64encode(&x25519_pub_from_ed25519_seed(&a_seed))
);
let mut trust_b = empty_trust();
add_agent_card_pin(&mut trust_b, &a_card, Some("VERIFIED"));
let a_handle = a_card["handle"].as_str().unwrap().to_string();
let a_did = a_card["did"].as_str().unwrap().to_string();
let b_did = _b_card["did"].as_str().unwrap().to_string();
let b_dh = x25519_pub_from_ed25519_seed(&b_seed);
let mut event = json!({
"from": a_did, "to": b_did, "type": "decision", "kind": 1000,
"body": "pipeline secret",
});
seal_event_body(&mut event, &b_dh, &a_seed).unwrap();
let signed = sign_message_v31(&event, &a_seed, &a_pk, &a_handle).unwrap();
let viewed = decrypt_event_for_read(&signed, &trust_b, &b_seed);
assert_eq!(viewed["body"], json!("pipeline secret"));
assert_eq!(viewed["dec"], json!(true));
}
const GOLDEN_PUB_A: &str = "1b1b58dd50ea14b60da17b790cd02754d970c9bab864ebb3c0f3016fe51d3f57";
const GOLDEN_PUB_B: &str = "60346e7c911a5f6ba154129174cafe75b294ac3bbd5549632f48cec6266f8410";
const GOLDEN_CONV_AB: &str = "9ade86510fe31aa30c0a583c7282a2cce1447103f2cd70e165489ac5b09dbd2e";
#[test]
#[ignore = "run with --ignored --nocapture to (re)capture golden literals"]
fn print_golden() {
eprintln!(
"PUB_A={}",
hex::encode(x25519_pub_from_ed25519_seed(&SEED_A))
);
eprintln!(
"PUB_B={}",
hex::encode(x25519_pub_from_ed25519_seed(&SEED_B))
);
eprintln!("CONV_AB={}", hex::encode(conv(&SEED_A, &SEED_B)));
}
}