313 lines
10 KiB
Rust
313 lines
10 KiB
Rust
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,
|
|
}
|
|
|
|
impl Keypair {
|
|
pub fn generate() -> Self {
|
|
let mut bytes = [0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|
Self {
|
|
signing: SigningKey::from_bytes(&bytes),
|
|
}
|
|
}
|
|
|
|
pub fn from_hex(s: &str) -> Result<Self> {
|
|
let raw = hex::decode(s.trim()).context("key is not hex")?;
|
|
let bytes: [u8; 32] = raw
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow!("key must be 32 bytes"))?;
|
|
Ok(Self {
|
|
signing: SigningKey::from_bytes(&bytes),
|
|
})
|
|
}
|
|
|
|
pub fn to_hex(&self) -> String {
|
|
hex::encode(self.signing.to_bytes())
|
|
}
|
|
|
|
pub fn public_hex(&self) -> String {
|
|
hex::encode(self.signing.verifying_key().to_bytes())
|
|
}
|
|
|
|
pub fn verifying_key(&self) -> VerifyingKey {
|
|
self.signing.verifying_key()
|
|
}
|
|
|
|
pub fn sign(&self, bytes: &[u8]) -> String {
|
|
hex::encode(self.signing.sign(bytes).to_bytes())
|
|
}
|
|
}
|
|
|
|
pub fn random_nonce() -> String {
|
|
let mut bytes = [0u8; 16];
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|
hex::encode(bytes)
|
|
}
|
|
|
|
pub fn random_id() -> String {
|
|
let mut bytes = [0u8; 8];
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|
hex::encode(bytes)
|
|
}
|
|
|
|
pub fn canonical_json(value: &Value) -> String {
|
|
let mut out = String::new();
|
|
write_canonical(value, &mut out);
|
|
out
|
|
}
|
|
|
|
fn write_canonical(value: &Value, out: &mut String) {
|
|
match value {
|
|
Value::Null => out.push_str("null"),
|
|
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
|
|
Value::Number(n) => out.push_str(&n.to_string()),
|
|
Value::String(s) => out.push_str(&serde_json::to_string(s).expect("string serializes")),
|
|
Value::Array(items) => {
|
|
out.push('[');
|
|
for (i, item) in items.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push(',');
|
|
}
|
|
write_canonical(item, out);
|
|
}
|
|
out.push(']');
|
|
}
|
|
Value::Object(map) => {
|
|
let mut keys: Vec<&String> = map.keys().collect();
|
|
keys.sort();
|
|
out.push('{');
|
|
for (i, key) in keys.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push(',');
|
|
}
|
|
out.push_str(&serde_json::to_string(key).expect("key serializes"));
|
|
out.push(':');
|
|
write_canonical(&map[*key], out);
|
|
}
|
|
out.push('}');
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn poll_signing_bytes(member: &str, nonce: &str) -> Vec<u8> {
|
|
format!("{}\npoll\n{}\n{}", PROTOCOL, member, nonce).into_bytes()
|
|
}
|
|
|
|
pub fn verify_signature(public_hex: &str, message: &[u8], sig_hex: &str) -> Result<()> {
|
|
let key_bytes = hex::decode(public_hex).context("public key is not hex")?;
|
|
let key_bytes: [u8; 32] = key_bytes
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow!("public key must be 32 bytes"))?;
|
|
let verifying_key =
|
|
VerifyingKey::from_bytes(&key_bytes).map_err(|e| anyhow!("bad public key: {e}"))?;
|
|
let sig_bytes = hex::decode(sig_hex).context("signature is not hex")?;
|
|
let sig_bytes: [u8; 64] = sig_bytes
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow!("signature must be 64 bytes"))?;
|
|
let signature = Signature::from_bytes(&sig_bytes);
|
|
verifying_key
|
|
.verify_strict(message, &signature)
|
|
.map_err(|_| anyhow!("signature verification failed"))
|
|
}
|
|
|
|
pub fn signing_bytes(envelope: &Envelope) -> Vec<u8> {
|
|
let unsigned = serde_json::json!({
|
|
"type": envelope.msg_type,
|
|
"from": envelope.from,
|
|
"key": envelope.key,
|
|
"ts": envelope.ts,
|
|
"nonce": envelope.nonce,
|
|
"body": envelope.body,
|
|
});
|
|
format!("{}\n{}", PROTOCOL, canonical_json(&unsigned)).into_bytes()
|
|
}
|
|
|
|
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)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn canonical_json_sorts_keys_recursively() {
|
|
let a = json!({"b": 1, "a": {"d": [3, 2], "c": "x"}});
|
|
let b = json!({"a": {"c": "x", "d": [3, 2]}, "b": 1});
|
|
assert_eq!(canonical_json(&a), canonical_json(&b));
|
|
assert_eq!(canonical_json(&a), r#"{"a":{"c":"x","d":[3,2]},"b":1}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn envelope_sign_verify_roundtrip() {
|
|
let key = Keypair::generate();
|
|
let env = Envelope::new(
|
|
&key,
|
|
"alice.example",
|
|
crate::message::TYPE_QUERY,
|
|
json!({"text": "hello"}),
|
|
);
|
|
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();
|
|
let mut env = Envelope::new(
|
|
&key,
|
|
"alice.example",
|
|
crate::message::TYPE_QUERY,
|
|
json!({"text": "hello"}),
|
|
);
|
|
env.body = json!({"text": "hello", "extra": true});
|
|
assert!(verify_envelope(&env).is_err());
|
|
}
|
|
}
|