pakery_spake2/transcript.rs
1//! Key schedule and output per RFC 9382 §4.
2
3use alloc::vec::Vec;
4use subtle::ConstantTimeEq;
5use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
6
7use pakery_core::crypto::{Hash, Kdf, Mac};
8use pakery_core::SharedSecret;
9
10use crate::ciphersuite::Spake2Ciphersuite;
11use crate::error::Spake2Error;
12
13/// Output of a completed SPAKE2 protocol run.
14///
15/// Contains the session key and confirmation MACs.
16#[derive(Zeroize, ZeroizeOnDrop)]
17pub struct Spake2Output {
18 /// The session key (Ke, first half of the hash).
19 #[zeroize(skip)]
20 pub session_key: SharedSecret,
21 /// This party's confirmation MAC to send to the peer.
22 pub confirmation_mac: Vec<u8>,
23 /// The expected MAC from the peer.
24 expected_peer_mac: Vec<u8>,
25}
26
27impl Spake2Output {
28 /// Verify the peer's confirmation MAC in constant time.
29 pub fn verify_peer_confirmation(&self, peer_mac: &[u8]) -> Result<(), Spake2Error> {
30 // ctgrind: the verification outcome is a public accept/reject
31 // decision; the comparison itself stays constant-time.
32 if pakery_core::ct::declassify_choice(self.expected_peer_mac.ct_eq(peer_mac)) {
33 Ok(())
34 } else {
35 Err(Spake2Error::ConfirmationFailed)
36 }
37 }
38
39 /// Consume the output and yield the session key.
40 ///
41 /// Because [`Spake2Output`] derives `ZeroizeOnDrop`, it cannot be
42 /// pattern-destructured by the caller. This consumer extracts the
43 /// session key cleanly without the boilerplate `mem::replace` shim
44 /// users would otherwise have to write themselves.
45 ///
46 /// Both `pub` fields remain accessible; clone the one you want to
47 /// keep before calling the consumer that takes the other:
48 ///
49 /// ```ignore
50 /// output.verify_peer_confirmation(peer_mac)?;
51 /// let confirmation_mac = output.confirmation_mac.clone();
52 /// let session_key = output.into_session_key();
53 /// ```
54 #[must_use]
55 pub fn into_session_key(mut self) -> SharedSecret {
56 core::mem::replace(&mut self.session_key, SharedSecret::new(Vec::new()))
57 }
58
59 /// Consume the output and yield this party's confirmation MAC.
60 ///
61 /// Mirror of [`Self::into_session_key`].
62 #[must_use]
63 pub fn into_confirmation_mac(mut self) -> Vec<u8> {
64 core::mem::take(&mut self.confirmation_mac)
65 }
66}
67
68/// Derive the key schedule from transcript TT.
69///
70/// Per RFC 9382 §4:
71/// 1. `Ke || Ka = Hash(TT)` (first NH/2 = Ke, second NH/2 = Ka)
72/// 2. `PRK = KDF.extract(salt=[], ikm=Ka)`
73/// 3. `KcA || KcB = KDF.expand(PRK, "ConfirmationKeys" || AAD, NH)`
74/// 4. `cA = MAC(KcA, TT)`, `cB = MAC(KcB, TT)`
75pub fn derive_key_schedule<C: Spake2Ciphersuite>(
76 tt: &[u8],
77 aad: &[u8],
78 is_party_a: bool,
79) -> Result<Spake2Output, Spake2Error> {
80 // Step 1: Hash(TT) → Ke || Ka
81 const { assert!(<C::Hash as pakery_core::crypto::Hash>::OUTPUT_SIZE >= C::NH) };
82 let hash_tt = Zeroizing::new(C::Hash::digest(tt));
83 let half = C::NH / 2;
84 let ke = &hash_tt[..half];
85 let ka = &hash_tt[half..C::NH];
86
87 // Step 2: PRK = KDF.extract(salt=[], ikm=Ka)
88 let prk = C::Kdf::extract(&[], ka);
89
90 // Step 3: KcA || KcB = KDF.expand(PRK, "ConfirmationKeys" || AAD, NH)
91 let mut info = Vec::from(b"ConfirmationKeys" as &[u8]);
92 info.extend_from_slice(aad);
93 let kc = C::Kdf::expand(&prk, &info, C::NH)
94 .map_err(|_| Spake2Error::InternalError("KDF expand failed"))?;
95 let kc_a = &kc[..half];
96 let kc_b = &kc[half..C::NH];
97
98 // Step 4: cA = MAC(KcA, TT), cB = MAC(KcB, TT)
99 let mac_a =
100 C::Mac::mac(kc_a, tt).map_err(|_| Spake2Error::InternalError("MAC computation failed"))?;
101 let mac_b =
102 C::Mac::mac(kc_b, tt).map_err(|_| Spake2Error::InternalError("MAC computation failed"))?;
103
104 let session_key = SharedSecret::new(ke.to_vec());
105
106 if is_party_a {
107 // ctgrind: our confirmation MAC goes on the wire — public once sent.
108 // The expected peer MAC stays secret until compared.
109 pakery_core::ct::declassify(&mac_a);
110 Ok(Spake2Output {
111 session_key,
112 confirmation_mac: mac_a,
113 expected_peer_mac: mac_b,
114 })
115 } else {
116 // ctgrind: our confirmation MAC goes on the wire — public once sent.
117 // The expected peer MAC stays secret until compared.
118 pakery_core::ct::declassify(&mac_b);
119 Ok(Spake2Output {
120 session_key,
121 confirmation_mac: mac_b,
122 expected_peer_mac: mac_a,
123 })
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use alloc::vec;
131
132 /// Calling `.zeroize()` on a live value must clear every secret field
133 /// (roadmap item 7: catches a future field added without zeroization).
134 /// `session_key` is `#[zeroize(skip)]` by design: `SharedSecret` zeroizes
135 /// itself on its own drop, so it must survive the struct-level call.
136 #[test]
137 fn zeroize_clears_all_secret_fields() {
138 let mut output = Spake2Output {
139 session_key: SharedSecret::new(vec![0xAA; 32]),
140 confirmation_mac: vec![0xBB; 64],
141 expected_peer_mac: vec![0xCC; 64],
142 };
143 output.zeroize();
144 assert!(output.confirmation_mac.is_empty());
145 assert!(output.expected_peer_mac.is_empty());
146 // Skipped field is untouched (self-zeroizing on its own drop).
147 assert_eq!(output.session_key.as_bytes(), &[0xAA; 32]);
148 }
149}