Skip to main content

joy_crypt/
pairwise.rs

1//! Pairwise X25519 ECDH for Crypt zone-key wrapping.
2//!
3//! When a member grants another member access to a zone, the granter
4//! wraps the zone key under a KEK derived from
5//! `ECDH(granter_x25519_secret, recipient_x25519_public)`. The recipient
6//! reproduces the same KEK with
7//! `ECDH(recipient_x25519_secret, granter_x25519_public)`. The two
8//! computations yield the same shared secret because Diffie-Hellman is
9//! commutative over the curve.
10//!
11//! HKDF-SHA256 derives the final KEK from the shared secret. The `info`
12//! parameter binds the wrap to a specific zone so the same
13//! (granter, recipient) pair gets distinct KEKs across zones.
14//!
15//! Self-wrap (auto-create) is the special case where granter and
16//! recipient are the same member: ECDH(self_secret, self_public) is a
17//! deterministic value that only the holder of `self_secret` can
18//! reproduce.
19
20use x25519_dalek::{PublicKey, StaticSecret};
21
22use crate::kdf::derive_hkdf_sha256;
23
24/// Compute the pairwise KEK between a local secret and a peer public,
25/// salted with `info` (typically the zone name plus a fixed tag).
26pub fn pairwise_kek(
27    my_x25519_secret: &[u8; 32],
28    peer_x25519_public: &[u8; 32],
29    info: &[u8],
30) -> [u8; 32] {
31    let secret = StaticSecret::from(*my_x25519_secret);
32    let peer = PublicKey::from(*peer_x25519_public);
33    let shared = secret.diffie_hellman(&peer);
34    derive_hkdf_sha256(shared.as_bytes(), b"crypt-pairwise-v1", info)
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40    use crate::identity::{Keypair, PublicKey as IdPublicKey};
41
42    fn roundtrip_pair() -> (Keypair, Keypair) {
43        (
44            Keypair::from_seed(&[1u8; 32]),
45            Keypair::from_seed(&[2u8; 32]),
46        )
47    }
48
49    #[test]
50    fn ecdh_is_symmetric() {
51        let (alice, bob) = roundtrip_pair();
52        let kek_a = pairwise_kek(
53            &alice.to_x25519_secret_bytes(),
54            &bob.public_key().to_x25519_public_bytes(),
55            b"zone:default",
56        );
57        let kek_b = pairwise_kek(
58            &bob.to_x25519_secret_bytes(),
59            &alice.public_key().to_x25519_public_bytes(),
60            b"zone:default",
61        );
62        assert_eq!(kek_a, kek_b);
63    }
64
65    #[test]
66    fn third_party_gets_different_kek() {
67        let (alice, bob) = roundtrip_pair();
68        let eve = Keypair::from_seed(&[9u8; 32]);
69        let kek_ab = pairwise_kek(
70            &alice.to_x25519_secret_bytes(),
71            &bob.public_key().to_x25519_public_bytes(),
72            b"zone:default",
73        );
74        let kek_eb = pairwise_kek(
75            &eve.to_x25519_secret_bytes(),
76            &bob.public_key().to_x25519_public_bytes(),
77            b"zone:default",
78        );
79        assert_ne!(kek_ab, kek_eb);
80    }
81
82    #[test]
83    fn distinct_zones_get_distinct_keks() {
84        let (alice, bob) = roundtrip_pair();
85        let kek_default = pairwise_kek(
86            &alice.to_x25519_secret_bytes(),
87            &bob.public_key().to_x25519_public_bytes(),
88            b"zone:default",
89        );
90        let kek_other = pairwise_kek(
91            &alice.to_x25519_secret_bytes(),
92            &bob.public_key().to_x25519_public_bytes(),
93            b"zone:customer-x",
94        );
95        assert_ne!(kek_default, kek_other);
96    }
97
98    #[test]
99    fn self_wrap_is_deterministic() {
100        let alice = Keypair::from_seed(&[5u8; 32]);
101        let pk = alice.public_key();
102        let a = pairwise_kek(
103            &alice.to_x25519_secret_bytes(),
104            &pk.to_x25519_public_bytes(),
105            b"zone:default",
106        );
107        let b = pairwise_kek(
108            &alice.to_x25519_secret_bytes(),
109            &pk.to_x25519_public_bytes(),
110            b"zone:default",
111        );
112        assert_eq!(a, b);
113    }
114
115    #[test]
116    fn id_public_key_helper_matches_keypair() {
117        // Sanity: PublicKey constructed from hex roundtrips to the
118        // same X25519 bytes as the original keypair.
119        let kp = Keypair::from_seed(&[3u8; 32]);
120        let pk_hex = kp.public_key().to_hex();
121        let parsed = IdPublicKey::from_hex(&pk_hex).unwrap();
122        assert_eq!(
123            parsed.to_x25519_public_bytes(),
124            kp.public_key().to_x25519_public_bytes()
125        );
126    }
127}