Skip to main content

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}