oc_crypto/agreement.rs
1//! Key agreement behind a trait: X25519 in software, P-256 in software or in a TPM.
2//!
3//! This module exists for one property: the private key may never leave
4//! hardware. `NCryptSecretAgreement` returns a shared secret, not the key, so
5//! deriving an AEAD key by "taking the private key and computing DH" is fundamentally impossible:
6//! key agreement must be an extension point rather than an implementation detail
7//! of sealing. That is why the sealing construction is implemented manually following
8//! RFC 9180 rather than using an off-the-shelf HPKE crate: that crate requires the key.
9//!
10//! The crate remains pure. This contains only the abstraction and software
11//! implementations; TPM support lives in `cc-keystore`, which depends on this trait,
12//! not the reverse.
13
14use zeroize::Zeroizing;
15
16use crate::CryptoError;
17
18/// Shared-secret length: 32 bytes for both X25519 and P-256.
19///
20/// The equality is not accidental and need not last: X25519 returns a
21/// point multiplication result, P-256 the shared point's X coordinate, both 32 bytes for
22/// 256-bit curves. A mechanism with a different field size would give a different value,
23/// turning this constant into a function of the mechanism.
24pub const SHARED_SECRET_LEN: usize = 32;
25
26/// The key-agreement shared secret, in **big-endian** order.
27///
28/// ## Why there is one constructor and why it names the byte order
29///
30/// The `spikes/tpm-ecdh` spike discovered something easily overlooked:
31/// `NCryptSecretAgreement` with Microsoft Platform Crypto Provider returns the shared
32/// secret in **little-endian** order, whereas all pure P-256 implementations, including
33/// `p256`, use the big-endian X-coordinate representation, as prescribed by RFC 5903 (ECDH
34/// for IKE).
35///
36/// Byte order left to a comment is a bug waiting to happen:
37/// a reversed secret yields a different AEAD key; the slot will not open, appearing
38/// as "file corrupted". The error would be found, but not at its actual source.
39///
40/// There is therefore **exactly one** constructor, and its name states the byte order.
41/// An implementation receiving NCrypt bytes must reverse them to invoke it
42/// truthfully; this cannot be silently forgotten, since the type has no other entry point.
43#[derive(Clone)]
44pub struct SharedSecret(Zeroizing<[u8; SHARED_SECRET_LEN]>);
45
46impl SharedSecret {
47 /// The only constructor. Bytes must be big-endian.
48 #[must_use]
49 pub fn from_be_bytes(bytes: [u8; SHARED_SECRET_LEN]) -> Self {
50 Self(Zeroizing::new(bytes))
51 }
52
53 /// Secret for key derivation. Crate-internal: the shared secret is not exposed externally.
54 pub(crate) fn expose(&self) -> &[u8; SHARED_SECRET_LEN] {
55 &self.0
56 }
57
58 /// Compare two secrets in constant time.
59 ///
60 /// A named method rather than derived `PartialEq`: derivation would compare
61 /// bytes conventionally, returning early at the first difference,
62 /// which is a guessing oracle (I-13). A type for which `==` is unsafe should not
63 /// provide it at all.
64 ///
65 /// Needed externally: the hardware agreement implementation must be checked against
66 /// the software implementation using identical keys, and comparing
67 /// secrets is the only way to do that.
68 #[must_use]
69 pub fn ct_eq(&self, other: &Self) -> bool {
70 use subtle::ConstantTimeEq as _;
71 bool::from(self.0.ct_eq(&*other.0))
72 }
73}
74
75// `Debug` печатает заглушку: секрет не попадает в логи и тексты ошибок (И-11).
76impl core::fmt::Debug for SharedSecret {
77 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
78 f.write_str("SharedSecret(<скрыт>)")
79 }
80}
81
82/// An agreement party: its own half is known; the peer's half is input.
83///
84/// The trait is deliberately narrow: two operations. All we need from a TPM is
85/// its public key and agreement with a peer; no private key appears in the trait, nor
86/// can one appear, or hardware implementations could not implement the very trait
87/// introduced for them.
88pub trait KeyAgreement {
89 /// This party's public key, in its wire representation.
90 ///
91 /// Returns a `Vec`, not an array: X25519 uses 32 bytes and P-256 uses 65
92 /// (an uncompressed SEC1 point). The mechanism determines the form, which `oc-format`
93 /// validates against the length table; this trait does not.
94 fn public_key(&self) -> Vec<u8>;
95
96 /// Agree on a shared secret with the other party's public key.
97 ///
98 /// Implementations must reject peer keys off the curve and points
99 /// of small order. This is materially different for P-256 and X25519:
100 /// any 32-byte string is a valid X25519 public key, whereas a P-256
101 /// point off the curve enables an invalid-curve attack, and `enc`
102 /// comes from a **hostile file**. Relying on the underlying library to perform
103 /// this check is insufficient; it must be stated as a contract.
104 fn agree(&self, peer_public: &[u8]) -> Result<SharedSecret, CryptoError>;
105}
106
107/// Software X25519: a party that possesses its private key.
108///
109/// Wraps an existing secret rather than introducing another key storage method.
110/// Ensures X25519 and P-256 paths pass through the same trait;
111/// otherwise one mechanism would have an abstraction and the other a direct call,
112/// allowing them to diverge unnoticed.
113pub struct X25519Agreement<'a> {
114 secret: &'a crate::secret::X25519Secret,
115}
116
117// `Debug` вручную и без ключа: производный напечатал бы приватный ключ, а секреты
118// не попадают ни в логи, ни в `Debug`, ни в тексты ошибок (И-11). Публичный ключ
119// тоже не печатается — он вычисляется, и `Debug` не место для вычислений.
120impl core::fmt::Debug for X25519Agreement<'_> {
121 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
122 f.write_str("X25519Agreement(<ключ скрыт>)")
123 }
124}
125
126impl<'a> X25519Agreement<'a> {
127 #[must_use]
128 pub fn new(secret: &'a crate::secret::X25519Secret) -> Self {
129 Self { secret }
130 }
131}
132
133impl KeyAgreement for X25519Agreement<'_> {
134 fn public_key(&self) -> Vec<u8> {
135 let sk = x25519_dalek::StaticSecret::from(*self.secret.expose());
136 x25519_dalek::PublicKey::from(&sk).to_bytes().to_vec()
137 }
138
139 fn agree(&self, peer_public: &[u8]) -> Result<SharedSecret, CryptoError> {
140 let peer: [u8; 32] = peer_public.try_into().map_err(|_| CryptoError::BadLength)?;
141 let sk = x25519_dalek::StaticSecret::from(*self.secret.expose());
142 let shared = sk.diffie_hellman(&x25519_dalek::PublicKey::from(peer));
143 // Нулевой секрет — точка малого порядка. Отсекается не здесь, а в выводе
144 // ключа: проверка там одна на оба механизма, и дублировать её значило бы
145 // однажды поправить только одну из двух копий.
146 Ok(SharedSecret::from_be_bytes(*shared.as_bytes()))
147 }
148}
149
150/// Software P-256: a keypair living in process memory.
151///
152/// Required for two reasons, both essential. First, testability: without it the entire
153/// P-256 slot path could only be tested on a TPM-equipped machine, meaning
154/// not at all in CI. Second, software binding: a device
155/// without a suitable TPM must work, honestly declaring software binding rather than
156/// refusing to start.
157pub struct P256Agreement {
158 secret: p256::SecretKey,
159}
160
161impl core::fmt::Debug for P256Agreement {
162 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
163 f.write_str("P256Agreement(<ключ скрыт>)")
164 }
165}
166
167impl P256Agreement {
168 /// Create a pair using an RNG passed as a parameter.
169 ///
170 /// The RNG must be a parameter: RNG dependencies are forbidden in this crate;
171 /// `getrandom` once leaked in transitively and was caught by a wasm32
172 /// build. Tests must also be deterministic.
173 ///
174 /// `generate_from_rng`, not `SecretKey::random`: the latter is deprecated.
175 /// The first attempt called nonexistent `generate`, caught by compilation;
176 /// the trait method uses its full name.
177 pub fn generate<R: rand_core::CryptoRng + ?Sized>(rng: &mut R) -> Self {
178 use p256::elliptic_curve::Generate as _;
179
180 Self { secret: p256::SecretKey::generate_from_rng(rng) }
181 }
182
183 /// Restore a pair from private-key bytes (a big-endian scalar).
184 pub fn from_be_bytes(bytes: &[u8; 32]) -> Result<Self, CryptoError> {
185 let secret = p256::SecretKey::from_slice(bytes).map_err(|_| CryptoError::BadKey)?;
186 Ok(Self { secret })
187 }
188}
189
190impl KeyAgreement for P256Agreement {
191 fn public_key(&self) -> Vec<u8> {
192 // Несжатая точка: `0x04 ‖ X ‖ Y`, 65 байт. Форма на проводе задана
193 // форматом (§2.0) и продиктована PCP, который сжатой не отдаёт.
194 use p256::elliptic_curve::sec1::ToSec1Point as _;
195
196 // `to_sec1_point(false)` — несжатая форма ЯВНО, а не по умолчанию
197 // библиотеки: значение уходит на провод, и его форма задана форматом.
198 // Смена умолчания в зависимости была бы сменой байтов контейнера.
199 self.secret.public_key().to_sec1_point(false).to_bytes().to_vec()
200 }
201
202 fn agree(&self, peer_public: &[u8]) -> Result<SharedSecret, CryptoError> {
203 // Разбор точки — он же проверка того, что она лежит на кривой:
204 // `from_sec1_bytes` отвергает и точку вне кривой, и точку в
205 // бесконечности. Для P-256 это не формальность, а защита от атаки
206 // invalid-curve: `enc` приходит из ВРАЖДЕБНОГО файла, и точка вне кривой
207 // позволяет вытягивать приватный ключ по частям. У X25519 такой проблемы
208 // нет — там законна любая 32-байтовая строка, — и именно поэтому проверку
209 // нельзя было оставить общей на два механизма.
210 let peer =
211 p256::PublicKey::from_sec1_bytes(peer_public).map_err(|_| CryptoError::BadKey)?;
212
213 let shared = p256::ecdh::diffie_hellman(self.secret.to_nonzero_scalar(), peer.as_affine());
214
215 // `raw_secret_bytes` отдаёт координату X в big-endian — то же
216 // представление, которое требует RFC 5903 и которое обещает
217 // `SharedSecret::from_be_bytes`. NCrypt на этом месте отдаёт
218 // little-endian, и разворот — обязанность аппаратной реализации.
219 let bytes: [u8; SHARED_SECRET_LEN] =
220 shared.raw_secret_bytes().as_slice().try_into().map_err(|_| CryptoError::BadLength)?;
221 Ok(SharedSecret::from_be_bytes(bytes))
222 }
223}
224
225#[cfg(test)]
226#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]
227mod tests {
228 use super::*;
229
230 /// Keys are specified as bytes rather than generated: the test must be
231 /// deterministic, and RNGs enter this crate only as parameters.
232 fn p256_pair(byte: u8) -> P256Agreement {
233 P256Agreement::from_be_bytes(&[byte; 32]).expect("скаляр в диапазоне")
234 }
235
236 #[test]
237 fn a_p256_public_key_is_sixty_five_bytes_of_uncompressed_point() {
238 let pk = p256_pair(0x11).public_key();
239 assert_eq!(pk.len(), 65, "форма на проводе задана форматом: несжатая точка");
240 assert_eq!(pk.first(), Some(&0x04), "префикс несжатой точки SEC1");
241 }
242
243 /// Both parties must reach the same secret, or the slot will not open,
244 /// appearing as file corruption.
245 #[test]
246 fn both_sides_of_a_p256_agreement_reach_the_same_secret() {
247 let a = p256_pair(0x11);
248 let b = p256_pair(0x22);
249
250 let from_a = a.agree(&b.public_key()).unwrap();
251 let from_b = b.agree(&a.public_key()).unwrap();
252 assert_eq!(from_a.expose(), from_b.expose());
253 }
254
255 #[test]
256 fn both_sides_of_an_x25519_agreement_reach_the_same_secret() {
257 let sa = crate::secret::X25519Secret::from_bytes([0x33; 32]);
258 let sb = crate::secret::X25519Secret::from_bytes([0x44; 32]);
259 let a = X25519Agreement::new(&sa);
260 let b = X25519Agreement::new(&sb);
261
262 let from_a = a.agree(&b.public_key()).unwrap();
263 let from_b = b.agree(&a.public_key()).unwrap();
264 assert_eq!(from_a.expose(), from_b.expose());
265 }
266
267 /// An off-curve point is rejected rather than fed into multiplication.
268 ///
269 /// This protects against invalid-curve attacks and is specifically needed for P-256: `enc`
270 /// comes from a hostile file, and an off-curve point allows extracting
271 /// parts of the private key by observing agreement results. X25519 needs
272 /// no such check: any 32-byte string is valid, a property
273 /// of the curve rather than a concession.
274 #[test]
275 fn a_point_off_the_curve_is_refused_rather_than_multiplied() {
276 let a = p256_pair(0x11);
277
278 // Правильная длина и правильный префикс, но координаты выдуманы.
279 let mut bogus = vec![0x04u8];
280 bogus.extend_from_slice(&[0xab; 64]);
281 assert_eq!(bogus.len(), 65, "длина верна: отказ обязан быть по кривой");
282 assert!(a.agree(&bogus).is_err(), "точка вне кривой принята");
283
284 // Точка в бесконечности — отдельный случай, тоже отказ.
285 assert!(a.agree(&[0x00]).is_err(), "точка в бесконечности принята");
286 }
287
288 /// Both implementations reject lengths inconsistent with the mechanism.
289 #[test]
290 fn a_public_key_of_the_wrong_length_is_refused() {
291 let p = p256_pair(0x11);
292 // Тридцать три байта с префиксом НЕсжатой точки — не форма SEC1 вообще:
293 // ни одна из двух форм так не выглядит. Сообщение называет именно это,
294 // а не «сжатую форму»: см. пробу ниже, где разобрано, почему прежняя
295 // формулировка была неправдой.
296 assert!(p.agree(&[0x04; 33]).is_err(), "P-256 принял 33 байта с префиксом 0x04");
297 assert!(p.agree(&[0x00; 32]).is_err(), "P-256 принял 32 байта");
298
299 let s = crate::secret::X25519Secret::from_bytes([0x55; 32]);
300 let x = X25519Agreement::new(&s);
301 assert!(x.agree(&[0x00; 65]).is_err(), "X25519 принял 65 байт");
302 }
303
304 /// `agree` ACCEPTS compressed points; the compressed-form prohibition belongs elsewhere.
305 ///
306 /// The neighboring probe previously claimed the opposite, "P-256 accepted compressed
307 /// form", while feeding `agree` thirty-three bytes of `0x04`. That is not a compressed
308 /// point: compressed points start with `0x02` or `0x03`; `0x04` denotes uncompressed form.
309 /// Rejection came from SEC1 parsing, so the probe passed for an unrelated
310 /// reason, while `agree` does not have the property it claimed at all:
311 /// `from_sec1_bytes` parses both forms, and both yield the same secret.
312 ///
313 /// The specification's requirement (§3.3: `enc` for `kem_id = 2` is exactly 65
314 /// bytes, an uncompressed point) is enforced by the EXACT LENGTH table in `oc-format`.
315 /// This separation is deliberate and documented on
316 /// [`KeyAgreement::agree`]: wire form is checked by the format, not the key-agreement
317 /// trait. Otherwise the hardware implementation would have to duplicate
318 /// the length table, and two copies of one table would diverge.
319 ///
320 /// The probe records this layer's actual behavior rather than a desired one; the verifiable
321 /// claim about compressed form lives in `oc-format`.
322 #[test]
323 fn a_compressed_p256_point_is_accepted_here_and_yields_the_same_secret() {
324 use p256::elliptic_curve::sec1::ToSec1Point as _;
325
326 let a = p256_pair(0x11);
327 let b = p256_pair(0x22);
328
329 let uncompressed = b.public_key();
330 let compressed = b.secret.public_key().to_sec1_point(true).to_bytes().to_vec();
331 assert_eq!(compressed.len(), 33, "сжатая точка SEC1 — 33 байта");
332 assert!(
333 matches!(compressed.first(), Some(0x02 | 0x03)),
334 "префикс сжатой точки: {:?}",
335 compressed.first()
336 );
337
338 let from_uncompressed = a.agree(&uncompressed).expect("несжатая форма отвергнута");
339 let from_compressed =
340 a.agree(&compressed).expect("сжатая форма отвергнута: поведение слоя изменилось");
341 assert!(
342 from_compressed.ct_eq(&from_uncompressed),
343 "сжатая и несжатая формы одного ключа дали разные секреты"
344 );
345 }
346
347 /// The secret is never printed, in logs or error text (I-11).
348 #[test]
349 fn a_shared_secret_never_prints_itself() {
350 let secret = SharedSecret::from_be_bytes([0xab; 32]);
351 let shown = format!("{secret:?}");
352 assert!(!shown.contains("ab"), "секрет попал в Debug: {shown}");
353 assert!(shown.contains("скрыт"));
354 }
355}