Skip to main content

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}