Skip to main content

pakery_spake2plus/
transcript.rs

1//! Key schedule and output per RFC 9383 section 3.4.
2
3use alloc::vec::Vec;
4use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
5
6use pakery_core::crypto::{Hash, Kdf, Mac};
7use pakery_core::SharedSecret;
8
9use crate::ciphersuite::Spake2PlusCiphersuite;
10use crate::error::Spake2PlusError;
11
12/// Output of a completed SPAKE2+ protocol run.
13#[derive(Zeroize, ZeroizeOnDrop)]
14pub struct Spake2PlusOutput {
15    /// The shared session key (K_shared).
16    #[zeroize(skip)]
17    pub session_key: SharedSecret,
18}
19
20impl Spake2PlusOutput {
21    /// Consume the output and yield the session key.
22    ///
23    /// Because [`Spake2PlusOutput`] derives `ZeroizeOnDrop`, it cannot be
24    /// pattern-destructured by the caller. This consumer extracts the
25    /// session key cleanly without the boilerplate `mem::replace` shim.
26    #[must_use]
27    pub fn into_session_key(mut self) -> SharedSecret {
28        core::mem::replace(&mut self.session_key, SharedSecret::new(Vec::new()))
29    }
30}
31
32/// Key schedule derived from the SPAKE2+ transcript.
33///
34/// Contains confirmation keys, MACs, and the shared session key.
35pub(crate) struct KeySchedule {
36    pub confirm_p: Vec<u8>,
37    pub confirm_v: Vec<u8>,
38    pub session_key: SharedSecret,
39}
40
41impl Zeroize for KeySchedule {
42    fn zeroize(&mut self) {
43        self.confirm_p.zeroize();
44        self.confirm_v.zeroize();
45        // SharedSecret also zeroizes on its own drop; clearing it here keeps
46        // `zeroize()` exhaustive over every secret field.
47        self.session_key.zeroize();
48    }
49}
50
51impl Drop for KeySchedule {
52    fn drop(&mut self) {
53        self.zeroize();
54    }
55}
56
57/// Derive the key schedule from transcript TT.
58///
59/// Per RFC 9383 section 3.4:
60/// 1. `K_main = Hash(TT)` (full NH-byte hash output)
61/// 2. `PRK = KDF.extract(salt=[], ikm=K_main)`
62/// 3. `K_confirmP || K_confirmV = KDF.expand(PRK, "ConfirmationKeys", 2*NH)`
63/// 4. `K_shared = KDF.expand(PRK, "SharedKey", NH)`
64/// 5. `confirmV = MAC(K_confirmV, shareP)`, `confirmP = MAC(K_confirmP, shareV)`
65pub(crate) fn derive_key_schedule<C: Spake2PlusCiphersuite>(
66    tt: &[u8],
67    share_p: &[u8],
68    share_v: &[u8],
69) -> Result<KeySchedule, Spake2PlusError> {
70    // Step 1: K_main = Hash(TT)
71    const { assert!(<C::Hash as pakery_core::crypto::Hash>::OUTPUT_SIZE >= C::NH) };
72    let k_main = Zeroizing::new(C::Hash::digest(tt));
73
74    // Step 2: PRK = KDF.extract(salt=[], ikm=K_main)
75    let prk = C::Kdf::extract(&[], &k_main[..C::NH]);
76
77    // Step 3: K_confirmP || K_confirmV = KDF.expand(PRK, "ConfirmationKeys", 2*NH)
78    let kc = C::Kdf::expand(&prk, b"ConfirmationKeys", 2 * C::NH)
79        .map_err(|_| Spake2PlusError::InternalError("KDF expand failed for ConfirmationKeys"))?;
80    let k_confirm_p = &kc[..C::NH];
81    let k_confirm_v = &kc[C::NH..2 * C::NH];
82
83    // Step 4: K_shared = KDF.expand(PRK, "SharedKey", NH)
84    let mut k_shared = C::Kdf::expand(&prk, b"SharedKey", C::NH)
85        .map_err(|_| Spake2PlusError::InternalError("KDF expand failed for SharedKey"))?;
86
87    // Step 5: confirmV = MAC(K_confirmV, shareP), confirmP = MAC(K_confirmP, shareV)
88    // Note: MACs are over the *peer's* share
89    let confirm_v = C::Mac::mac(k_confirm_v, share_p)
90        .map_err(|_| Spake2PlusError::InternalError("MAC computation failed"))?;
91    let confirm_p = C::Mac::mac(k_confirm_p, share_v)
92        .map_err(|_| Spake2PlusError::InternalError("MAC computation failed"))?;
93
94    Ok(KeySchedule {
95        confirm_p,
96        confirm_v,
97        session_key: SharedSecret::new(core::mem::take(&mut *k_shared)),
98    })
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use alloc::vec;
105
106    /// Calling `.zeroize()` on a live value must clear every secret field
107    /// (roadmap item 7: catches a future field added without zeroization).
108    /// `session_key` is `#[zeroize(skip)]` by design: `SharedSecret` zeroizes
109    /// itself on its own drop, so it must survive the struct-level call.
110    #[test]
111    fn output_zeroize_leaves_self_zeroizing_session_key() {
112        let mut output = Spake2PlusOutput {
113            session_key: SharedSecret::new(vec![0xAA; 32]),
114        };
115        output.zeroize();
116        // Skipped field is untouched (self-zeroizing on its own drop).
117        assert_eq!(output.session_key.as_bytes(), &[0xAA; 32]);
118    }
119
120    /// The manual `Zeroize` impl (called from `Drop`) must clear every
121    /// secret field of the key schedule.
122    #[test]
123    fn key_schedule_zeroize_clears_all_secret_fields() {
124        let mut ks = KeySchedule {
125            confirm_p: vec![0xAA; 64],
126            confirm_v: vec![0xBB; 64],
127            session_key: SharedSecret::new(vec![0xCC; 32]),
128        };
129        ks.zeroize();
130        assert!(ks.confirm_p.is_empty());
131        assert!(ks.confirm_v.is_empty());
132        assert!(ks.session_key.as_bytes().is_empty());
133    }
134}