Skip to main content

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}