openrtc 1.0.2

OpenRTC: a Rust-first P2P runtime for device discovery, signaling, and iroh/QUIC networking.
Documentation
//! Ephemeral X25519 ECDH key agreement for automatic application-layer crypto.
//!
//! Peers exchange 32-byte Curve25519 public keys during transport handshake and
//! derive a shared 32-byte `ORTCE1` key with HKDF-SHA256. Node ids are mixed into
//! the HKDF info string in sorted order so both sides derive the same key.

use crate::application_crypto::APPLICATION_KEY_BYTES;
use hkdf::Hkdf;
use sha2::Digest;
use sha2::Sha256;
use x25519_dalek::{PublicKey, StaticSecret};

pub const KEY_AGREEMENT_PUBLIC_KEY_BYTES: usize = 32;
pub const KEY_AGREEMENT_ALGORITHM: &str = "openrtc:x25519-hkdf:v1";
pub const KEY_AGREEMENT_HKDF_INFO: &[u8] = b"openrtc:application-crypto:v1";

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyAgreementError {
    InvalidLocalSecretLength,
    InvalidRemotePublicKeyLength,
    InvalidRemotePublicKey,
    HkdfExpandFailed,
    RandomSecretFailed,
}

#[derive(Clone)]
pub struct EphemeralKeyAgreement {
    secret: StaticSecret,
    public: PublicKey,
}

impl EphemeralKeyAgreement {
    pub fn generate() -> Result<Self, KeyAgreementError> {
        let mut secret_bytes = [0u8; KEY_AGREEMENT_PUBLIC_KEY_BYTES];
        getrandom::getrandom(&mut secret_bytes)
            .map_err(|_| KeyAgreementError::RandomSecretFailed)?;
        Ok(Self::from_secret_bytes(secret_bytes))
    }

    pub fn from_secret_bytes(bytes: [u8; KEY_AGREEMENT_PUBLIC_KEY_BYTES]) -> Self {
        let secret = StaticSecret::from(bytes);
        let public = PublicKey::from(&secret);
        Self { secret, public }
    }

    pub fn public_key_bytes(&self) -> [u8; KEY_AGREEMENT_PUBLIC_KEY_BYTES] {
        *self.public.as_bytes()
    }

    pub fn derive_application_crypto_key(
        &self,
        remote_public_key: &[u8; KEY_AGREEMENT_PUBLIC_KEY_BYTES],
        local_node_id: &str,
        remote_node_id: &str,
    ) -> Result<[u8; APPLICATION_KEY_BYTES], KeyAgreementError> {
        derive_application_crypto_key(
            &self.secret.to_bytes(),
            remote_public_key,
            local_node_id,
            remote_node_id,
        )
    }
}

pub fn derive_application_crypto_key(
    local_secret: &[u8; KEY_AGREEMENT_PUBLIC_KEY_BYTES],
    remote_public_key: &[u8; KEY_AGREEMENT_PUBLIC_KEY_BYTES],
    local_node_id: &str,
    remote_node_id: &str,
) -> Result<[u8; APPLICATION_KEY_BYTES], KeyAgreementError> {
    let local_secret = StaticSecret::from(*local_secret);
    let remote_public = PublicKey::from(*remote_public_key);
    let shared = local_secret.diffie_hellman(&remote_public);
    let info = build_hkdf_info(local_node_id, remote_node_id);
    let hkdf = Hkdf::<Sha256>::new(None, shared.as_bytes());
    let mut okm = [0u8; APPLICATION_KEY_BYTES];
    hkdf.expand(&info, &mut okm)
        .map_err(|_| KeyAgreementError::HkdfExpandFailed)?;
    Ok(okm)
}

pub fn key_agreement_fingerprint(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    hex::encode(&digest[..6])
}

fn build_hkdf_info(local_node_id: &str, remote_node_id: &str) -> Vec<u8> {
    let (first, second) = if local_node_id <= remote_node_id {
        (local_node_id, remote_node_id)
    } else {
        (remote_node_id, local_node_id)
    };
    let mut info =
        Vec::with_capacity(KEY_AGREEMENT_HKDF_INFO.len() + 1 + first.len() + 1 + second.len());
    info.extend_from_slice(KEY_AGREEMENT_HKDF_INFO);
    info.push(0);
    info.extend_from_slice(first.as_bytes());
    info.push(0);
    info.extend_from_slice(second.as_bytes());
    info
}

#[cfg(test)]
mod tests {
    use super::*;

    const ALICE_SECRET: [u8; 32] = [
        0x77, 0x00, 0x76, 0x6d, 0x0a, 0x73, 0x18, 0x65, 0x7d, 0x3c, 0x16, 0xc1, 0x72, 0x51, 0xb2,
        0x66, 0x45, 0xdf, 0x4c, 0x2f, 0x87, 0xeb, 0xc0, 0x99, 0x2a, 0xb1, 0x77, 0xfb, 0xa5, 0x1d,
        0xb9, 0x2c,
    ];
    const BOB_SECRET: [u8; 32] = [
        0x5d, 0xab, 0x08, 0x7e, 0x62, 0x4a, 0x8a, 0x4b, 0x79, 0xe1, 0x7f, 0x8b, 0x83, 0x80, 0x0e,
        0xe6, 0x6f, 0x3b, 0xb1, 0x29, 0x26, 0x18, 0xb6, 0xfd, 0x1c, 0x2f, 0x8b, 0x27, 0xff, 0x88,
        0xe0, 0xeb,
    ];
    #[test]
    fn derive_application_crypto_key_is_symmetric_and_matches_typescript_fixture() {
        let alice = EphemeralKeyAgreement::from_secret_bytes(ALICE_SECRET);
        let bob = EphemeralKeyAgreement::from_secret_bytes(BOB_SECRET);
        let local = "node-alice";
        let remote = "node-bob";

        let alice_key = alice
            .derive_application_crypto_key(&bob.public_key_bytes(), local, remote)
            .unwrap();
        let bob_key = bob
            .derive_application_crypto_key(&alice.public_key_bytes(), remote, local)
            .unwrap();

        assert_eq!(alice_key, bob_key);
        assert_eq!(
            hex::encode(alice_key),
            "42aca5e6940349d360f4ae11e9cd0afeac27ed3d80d0d29b477165956fdf7a6c",
        );
    }

    #[test]
    fn derive_application_crypto_key_orders_node_ids_deterministically() {
        let alice = EphemeralKeyAgreement::from_secret_bytes(ALICE_SECRET);
        let bob = EphemeralKeyAgreement::from_secret_bytes(BOB_SECRET);

        let forward = alice
            .derive_application_crypto_key(&bob.public_key_bytes(), "zzz-node", "aaa-node")
            .unwrap();
        let reverse = bob
            .derive_application_crypto_key(&alice.public_key_bytes(), "aaa-node", "zzz-node")
            .unwrap();

        assert_eq!(forward, reverse);
    }
}