Skip to main content

hap_crypto/
x25519.rs

1//! X25519 (Curve25519) ephemeral Diffie-Hellman for Pair Verify (M3).
2//!
3//! HomeKit Pair Verify opens each session with a fresh ephemeral X25519 key
4//! exchange: the controller sends an ephemeral public key in M1, the accessory
5//! replies with its own in M2, and both sides derive the same 32-byte shared
6//! secret. That shared secret is the input keying material for the HKDF-SHA512
7//! derivation of the Pair-Verify encryption key and the directional session
8//! keys (handled elsewhere in M3).
9//!
10//! The curve arithmetic is never reimplemented here; it comes from the vetted
11//! [`x25519_dalek`] crate. This module only adapts the primitive to the
12//! fixed-size byte shapes the protocol code needs and pins it to the published
13//! RFC 7748 known-answer vector.
14//!
15//! X25519 is infallible for the fixed 32-byte inputs used here, so nothing in
16//! this module returns a `Result`.
17
18use x25519_dalek::{PublicKey, StaticSecret};
19
20/// An ephemeral X25519 keypair for a single Pair Verify exchange.
21///
22/// "Ephemeral" here means *per session*: a new keypair is generated with
23/// [`EphemeralKeypair::generate`] for each Pair Verify run and discarded once
24/// the session keys are derived. [`EphemeralKeypair::from_secret`] reconstructs
25/// a keypair from a fixed scalar for deterministic tests and trace replay.
26///
27/// The underlying secret is held as a [`StaticSecret`] (which zeroizes on drop)
28/// rather than exposed as raw bytes; only the public key and the computed shared
29/// secret leave this type.
30pub struct EphemeralKeypair {
31    secret: StaticSecret,
32    public: [u8; 32],
33}
34
35impl EphemeralKeypair {
36    /// Generate a fresh random ephemeral keypair using the operating system
37    /// CSPRNG.
38    ///
39    /// This is the constructor production code uses; every Pair Verify session
40    /// gets its own keypair.
41    #[must_use]
42    pub fn generate() -> Self {
43        let secret = StaticSecret::random();
44        let public = PublicKey::from(&secret).to_bytes();
45        Self { secret, public }
46    }
47
48    /// Reconstruct a keypair from a fixed 32-byte secret scalar.
49    ///
50    /// The scalar is clamped internally by X25519, so any 32-byte value is
51    /// accepted and the resulting public key matches the X25519 definition.
52    /// This is the deterministic path used to replay captured traces and to
53    /// assert the RFC 7748 known-answer vector; production code calls
54    /// [`EphemeralKeypair::generate`] instead.
55    #[must_use]
56    pub fn from_secret(scalar: [u8; 32]) -> Self {
57        let secret = StaticSecret::from(scalar);
58        let public = PublicKey::from(&secret).to_bytes();
59        Self { secret, public }
60    }
61
62    /// This keypair's X25519 public key — the 32 bytes sent on the wire.
63    #[must_use]
64    pub fn public(&self) -> [u8; 32] {
65        self.public
66    }
67
68    /// Compute the X25519 shared secret against the peer's 32-byte public key.
69    ///
70    /// Both sides of a Pair Verify exchange derive the same value:
71    /// `DH(self_secret, peer_public) == DH(peer_secret, self_public)`.
72    #[must_use]
73    pub fn diffie_hellman(&self, peer_public: &[u8; 32]) -> [u8; 32] {
74        let peer = PublicKey::from(*peer_public);
75        self.secret.diffie_hellman(&peer).to_bytes()
76    }
77}
78
79/// Compute an X25519 shared secret from a raw 32-byte secret scalar and a peer's
80/// 32-byte public key.
81///
82/// A free-function convenience equivalent to
83/// `EphemeralKeypair::from_secret(secret).diffie_hellman(peer_public)`, useful
84/// where only the shared secret is needed (e.g. trace replay) without holding a
85/// keypair.
86#[must_use]
87pub fn x25519_shared(secret: &[u8; 32], peer_public: &[u8; 32]) -> [u8; 32] {
88    let secret = StaticSecret::from(*secret);
89    let peer = PublicKey::from(*peer_public);
90    secret.diffie_hellman(&peer).to_bytes()
91}
92
93#[cfg(test)]
94// Test code only: CLAUDE.md carves out `unwrap`/`expect` for tests with a
95// documented justification. A failed `unwrap` here is itself a test failure.
96#[allow(clippy::unwrap_used, clippy::expect_used)]
97mod tests {
98    use super::*;
99
100    fn h(s: &str) -> [u8; 32] {
101        hex::decode(s).unwrap().try_into().unwrap()
102    }
103
104    // RFC 7748 §6.1 "Curve25519" known-answer vector. The published hex literals
105    // for Alice's and Bob's private/public keys and the resulting shared secret
106    // K. See <https://www.rfc-editor.org/rfc/rfc7748#section-6.1>.
107    const ALICE_PRIV: &str = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a";
108    const ALICE_PUB: &str = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a";
109    const BOB_PRIV: &str = "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb";
110    const BOB_PUB: &str = "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f";
111    const SHARED_K: &str = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742";
112
113    #[test]
114    fn alice_public_matches_rfc7748() {
115        assert_eq!(
116            EphemeralKeypair::from_secret(h(ALICE_PRIV)).public(),
117            h(ALICE_PUB)
118        );
119    }
120
121    #[test]
122    fn bob_public_matches_rfc7748() {
123        assert_eq!(
124            EphemeralKeypair::from_secret(h(BOB_PRIV)).public(),
125            h(BOB_PUB)
126        );
127    }
128
129    #[test]
130    fn alice_dh_bob_pub_matches_rfc7748_k() {
131        let alice = EphemeralKeypair::from_secret(h(ALICE_PRIV));
132        assert_eq!(alice.diffie_hellman(&h(BOB_PUB)), h(SHARED_K));
133    }
134
135    #[test]
136    fn bob_dh_alice_pub_matches_rfc7748_k() {
137        let bob = EphemeralKeypair::from_secret(h(BOB_PRIV));
138        assert_eq!(bob.diffie_hellman(&h(ALICE_PUB)), h(SHARED_K));
139    }
140
141    #[test]
142    fn dh_is_symmetric() {
143        let alice = EphemeralKeypair::from_secret(h(ALICE_PRIV));
144        let bob = EphemeralKeypair::from_secret(h(BOB_PRIV));
145        assert_eq!(
146            alice.diffie_hellman(&bob.public()),
147            bob.diffie_hellman(&alice.public())
148        );
149    }
150
151    #[test]
152    fn free_function_matches_rfc7748_k() {
153        assert_eq!(x25519_shared(&h(ALICE_PRIV), &h(BOB_PUB)), h(SHARED_K));
154        assert_eq!(x25519_shared(&h(BOB_PRIV), &h(ALICE_PUB)), h(SHARED_K));
155    }
156
157    #[test]
158    fn generate_then_exchange_roundtrips() {
159        let alice = EphemeralKeypair::generate();
160        let bob = EphemeralKeypair::generate();
161        assert_eq!(alice.public().len(), 32);
162        assert_eq!(
163            alice.diffie_hellman(&bob.public()),
164            bob.diffie_hellman(&alice.public())
165        );
166    }
167}