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}