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