Skip to main content

pamoja_session/
kex.rs

1//! X25519 key agreement (RFC 7748) and HKDF-SHA256 (RFC 5869): how two devices that
2//! already hold each other's public key arrive at the same session key without ever
3//! sending it.
4//!
5//! The raw X25519 shared secret is never used as a key directly. It is run through
6//! HKDF-SHA256, salted with a fresh per-session value and bound to both public keys,
7//! so each session gets an independent key and the key is tied to the specific pair
8//! of devices. The tests pin both primitives to their RFC reference vectors.
9
10use x25519_dalek::{PublicKey, StaticSecret};
11
12use crate::kdf::hkdf_sha256;
13
14// The label mixed into every derivation, so a pamoja session key can never collide
15// with a key some other protocol derives from the same shared secret.
16const CONTEXT: &[u8] = b"pamoja-session v1";
17
18// The derived output: 32 bytes of session key followed by a 3-byte nonce prefix.
19pub(crate) const OKM_LEN: usize = 35;
20
21/// A device's long-term key-agreement secret.
22///
23/// This is the private half a device uses to agree a session key with a peer. It is
24/// built from a 32-byte seed the device is provisioned with and keeps in secure
25/// storage, so the same agreement key is recreated deterministically across reboots.
26///
27/// It is separate from the device's ed25519 signing identity in `pamoja-security`.
28/// Key agreement gives confidentiality; it does not by itself prove who the peer is.
29/// A deployment authenticates the peer by pinning its [`public`](AgreementKey::public)
30/// value, or by signing that value with the peer's `pamoja-security` identity, the
31/// same way it already pins a signing identity. Without that pinning the channel is
32/// private but unauthenticated and a man in the middle is possible.
33///
34/// # Examples
35///
36/// ```
37/// use pamoja_session::AgreementKey;
38///
39/// let device = AgreementKey::from_seed(&[7u8; 32]);
40/// let public = device.public();
41/// // `public.to_bytes()` is what a peer pins or has signed to trust this device.
42/// assert_eq!(public.to_bytes().len(), 32);
43/// ```
44pub struct AgreementKey {
45    secret: StaticSecret,
46}
47
48impl AgreementKey {
49    /// Builds a key-agreement secret from a 32-byte seed.
50    ///
51    /// # Arguments
52    ///
53    /// * `seed` - the 32 secret bytes the key is derived from.
54    ///
55    /// # Returns
56    ///
57    /// The agreement key.
58    pub fn from_seed(seed: &[u8; 32]) -> Self {
59        Self {
60            secret: StaticSecret::from(*seed),
61        }
62    }
63
64    /// Returns the public key a peer needs to agree a session with this device.
65    ///
66    /// # Returns
67    ///
68    /// The matching [`AgreementPublicKey`], safe to share once it is authenticated.
69    pub fn public(&self) -> AgreementPublicKey {
70        AgreementPublicKey {
71            inner: PublicKey::from(&self.secret),
72        }
73    }
74
75    // Computes the raw X25519 shared secret with a peer. Callers feed this into
76    // `derive`; it is never used as a key on its own.
77    pub(crate) fn shared_secret(&self, peer: &AgreementPublicKey) -> [u8; 32] {
78        self.secret.diffie_hellman(&peer.inner).to_bytes()
79    }
80}
81
82/// The public half of a device's key-agreement key.
83///
84/// A device holds the authenticated public keys of the peers it will talk to and
85/// uses them to agree a session key. It is 32 bytes on the wire.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct AgreementPublicKey {
88    inner: PublicKey,
89}
90
91impl AgreementPublicKey {
92    /// Reconstructs a public key from its 32-byte form.
93    ///
94    /// # Arguments
95    ///
96    /// * `bytes` - the 32-byte encoded public key.
97    ///
98    /// # Returns
99    ///
100    /// The public key. Every 32-byte value is a syntactically valid X25519 public
101    /// key, so this cannot fail; authenticating that the key belongs to the expected
102    /// device is the caller's responsibility.
103    pub fn from_bytes(bytes: &[u8; 32]) -> Self {
104        Self {
105            inner: PublicKey::from(*bytes),
106        }
107    }
108
109    /// Returns the 32-byte wire form of this public key.
110    ///
111    /// # Returns
112    ///
113    /// The public key encoded as 32 bytes.
114    pub fn to_bytes(&self) -> [u8; 32] {
115        self.inner.to_bytes()
116    }
117}
118
119// Derives the session key material from a shared secret, salted per session and bound
120// to both public keys. Returns 32 key bytes plus a 3-byte nonce prefix.
121pub(crate) fn derive(
122    shared: &[u8; 32],
123    salt: &[u8],
124    initiator: &[u8; 32],
125    responder: &[u8; 32],
126) -> [u8; OKM_LEN] {
127    const INFO_LEN: usize = CONTEXT.len() + 64;
128    let mut info = [0u8; INFO_LEN];
129    info[..CONTEXT.len()].copy_from_slice(CONTEXT);
130    info[CONTEXT.len()..CONTEXT.len() + 32].copy_from_slice(initiator);
131    info[CONTEXT.len() + 32..].copy_from_slice(responder);
132
133    let mut okm = [0u8; OKM_LEN];
134    hkdf_sha256(salt, shared, &info, &mut okm);
135    okm
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    // RFC 7748 section 6.1: Alice and Bob, with the public keys and shared secret the
143    // example computes.
144    const ALICE_SEED: [u8; 32] = [
145        0x77, 0x07, 0x6d, 0x0a, 0x73, 0x18, 0xa5, 0x7d, 0x3c, 0x16, 0xc1, 0x72, 0x51, 0xb2, 0x66,
146        0x45, 0xdf, 0x4c, 0x2f, 0x87, 0xeb, 0xc0, 0x99, 0x2a, 0xb1, 0x77, 0xfb, 0xa5, 0x1d, 0xb9,
147        0x2c, 0x2a,
148    ];
149    const ALICE_PUBLIC: [u8; 32] = [
150        0x85, 0x20, 0xf0, 0x09, 0x89, 0x30, 0xa7, 0x54, 0x74, 0x8b, 0x7d, 0xdc, 0xb4, 0x3e, 0xf7,
151        0x5a, 0x0d, 0xbf, 0x3a, 0x0d, 0x26, 0x38, 0x1a, 0xf4, 0xeb, 0xa4, 0xa9, 0x8e, 0xaa, 0x9b,
152        0x4e, 0x6a,
153    ];
154    const BOB_SEED: [u8; 32] = [
155        0x5d, 0xab, 0x08, 0x7e, 0x62, 0x4a, 0x8a, 0x4b, 0x79, 0xe1, 0x7f, 0x8b, 0x83, 0x80, 0x0e,
156        0xe6, 0x6f, 0x3b, 0xb1, 0x29, 0x26, 0x18, 0xb6, 0xfd, 0x1c, 0x2f, 0x8b, 0x27, 0xff, 0x88,
157        0xe0, 0xeb,
158    ];
159    const BOB_PUBLIC: [u8; 32] = [
160        0xde, 0x9e, 0xdb, 0x7d, 0x7b, 0x7d, 0xc1, 0xb4, 0xd3, 0x5b, 0x61, 0xc2, 0xec, 0xe4, 0x35,
161        0x37, 0x3f, 0x83, 0x43, 0xc8, 0x5b, 0x78, 0x67, 0x4d, 0xad, 0xfc, 0x7e, 0x14, 0x6f, 0x88,
162        0x2b, 0x4f,
163    ];
164    const SHARED: [u8; 32] = [
165        0x4a, 0x5d, 0x9d, 0x5b, 0xa4, 0xce, 0x2d, 0xe1, 0x72, 0x8e, 0x3b, 0xf4, 0x80, 0x35, 0x0f,
166        0x25, 0xe0, 0x7e, 0x21, 0xc9, 0x47, 0xd1, 0x9e, 0x33, 0x76, 0xf0, 0x9b, 0x3c, 0x1e, 0x16,
167        0x17, 0x42,
168    ];
169
170    #[test]
171    fn public_keys_match_the_rfc_7748_vector() {
172        assert_eq!(
173            AgreementKey::from_seed(&ALICE_SEED).public().to_bytes(),
174            ALICE_PUBLIC
175        );
176        assert_eq!(
177            AgreementKey::from_seed(&BOB_SEED).public().to_bytes(),
178            BOB_PUBLIC
179        );
180    }
181
182    #[test]
183    fn both_sides_agree_the_rfc_7748_shared_secret() {
184        let alice = AgreementKey::from_seed(&ALICE_SEED);
185        let bob = AgreementKey::from_seed(&BOB_SEED);
186        assert_eq!(alice.shared_secret(&bob.public()), SHARED);
187        assert_eq!(bob.shared_secret(&alice.public()), SHARED);
188    }
189
190    #[test]
191    fn a_public_key_round_trips_through_bytes() {
192        let public = AgreementKey::from_seed(&ALICE_SEED).public();
193        assert_eq!(AgreementPublicKey::from_bytes(&public.to_bytes()), public);
194    }
195
196    #[test]
197    fn derive_changes_with_the_salt() {
198        let first = derive(&SHARED, b"salt-one", &ALICE_PUBLIC, &BOB_PUBLIC);
199        let second = derive(&SHARED, b"salt-two", &ALICE_PUBLIC, &BOB_PUBLIC);
200        assert_ne!(first, second);
201    }
202}