Skip to main content

hap_crypto/
keys.rs

1//! Ed25519 controller long-term identity keypair for Pair Setup.
2//!
3//! The controller holds a long-term Ed25519 keypair (the controller's "long-term
4//! public key", LTPK) that identifies it to accessories across pairings. During
5//! Pair Setup M5 the controller signs its pairing material with this key; the
6//! accessory stores the LTPK and verifies future sessions against it.
7//!
8//! The Ed25519 primitive is never reimplemented; it comes from
9//! [`ed25519_dalek`].
10
11use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
12use rand_core::OsRng;
13
14use crate::error::{CryptoError, Result};
15
16/// A controller's long-term Ed25519 identity used across pairings.
17///
18/// `id` is the controller's pairing identifier (`iOSDevicePairingID`) — an
19/// arbitrary UTF-8 string the accessory stores alongside the public key.
20#[derive(Clone)]
21pub struct ControllerKeypair {
22    /// The controller's pairing identifier.
23    pub id: String,
24    signing: SigningKey,
25}
26
27impl ControllerKeypair {
28    /// Generate a fresh random Ed25519 keypair bound to `id`, using the
29    /// operating system CSPRNG ([`OsRng`]).
30    #[must_use]
31    pub fn generate(id: String) -> Self {
32        let signing = SigningKey::generate(&mut OsRng);
33        Self { id, signing }
34    }
35
36    /// Reconstruct a keypair deterministically from a stored 32-byte Ed25519
37    /// seed bound to `id`.
38    ///
39    /// Used by the test/replay harness and by persistence layers that store the
40    /// raw seed. The same `seed` always yields the same keypair.
41    #[must_use]
42    pub fn from_seed(id: String, seed: [u8; 32]) -> Self {
43        Self {
44            id,
45            signing: SigningKey::from_bytes(&seed),
46        }
47    }
48
49    /// The controller's long-term public key (LTPK): the 32-byte Ed25519 public
50    /// key.
51    #[must_use]
52    pub fn ltpk(&self) -> [u8; 32] {
53        self.signing.verifying_key().to_bytes()
54    }
55
56    /// The controller's 32-byte Ed25519 secret seed.
57    ///
58    /// This is **sensitive long-term private key material** — the inverse of
59    /// [`from_seed`](Self::from_seed). It exists so a persistence layer (e.g.
60    /// `hap-pairing`'s `JsonFileStore`) can save and later restore the controller
61    /// identity. Store it only in a location you would treat as secret.
62    #[must_use]
63    pub fn seed(&self) -> [u8; 32] {
64        self.signing.to_bytes()
65    }
66
67    /// Sign `msg` with the controller's Ed25519 key, producing a 64-byte
68    /// detached signature.
69    #[must_use]
70    pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
71        self.signing.sign(msg).to_bytes()
72    }
73
74    /// Clone the underlying Ed25519 signing key.
75    ///
76    /// Used internally by Pair Verify so a [`crate::pair_verify::PairVerifyClient`]
77    /// can own a signer for the duration of the exchange without requiring the
78    /// caller's [`ControllerKeypair`] to outlive it. The signing key is sensitive
79    /// material, so this stays crate-private.
80    pub(crate) fn signing_key(&self) -> SigningKey {
81        self.signing.clone()
82    }
83}
84
85impl PartialEq for ControllerKeypair {
86    fn eq(&self, other: &Self) -> bool {
87        self.id == other.id && self.seed() == other.seed()
88    }
89}
90
91impl Eq for ControllerKeypair {}
92
93/// Verify an Ed25519 `sig` over `msg` against a 32-byte public key `ltpk`.
94///
95/// Uses `ed25519-dalek`'s strict verification (rejecting small-order /
96/// non-canonical public keys).
97///
98/// # Errors
99///
100/// Returns [`CryptoError::Signature`] if `ltpk` is not a valid Ed25519 public
101/// key or if the signature does not verify over `msg`.
102pub fn verify_ed25519(ltpk: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<()> {
103    let vk = VerifyingKey::from_bytes(ltpk).map_err(|_| CryptoError::Signature)?;
104    let signature = Signature::from_bytes(sig);
105    vk.verify_strict(msg, &signature)
106        .map_err(|_| CryptoError::Signature)
107}
108
109#[cfg(test)]
110// Test code only: CLAUDE.md carves out `unwrap`/`expect` for tests with a
111// documented justification. A failed `unwrap` here is itself a test failure.
112#[allow(clippy::unwrap_used, clippy::expect_used)]
113mod tests {
114    use super::*;
115
116    fn h(s: &str) -> Vec<u8> {
117        hex::decode(s).unwrap()
118    }
119
120    // RFC 8032 §7.1 "Test Vectors for Ed25519", TEST 2 (1-byte message).
121    const RFC8032_SEED: &str = "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb";
122    const RFC8032_PUBLIC: &str = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c";
123    const RFC8032_MESSAGE: &str = "72";
124    const RFC8032_SIGNATURE: &str = "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00";
125
126    fn seed() -> [u8; 32] {
127        h(RFC8032_SEED).try_into().unwrap()
128    }
129
130    #[test]
131    fn from_seed_matches_rfc8032_public_key() {
132        let kp = ControllerKeypair::from_seed("controller".to_string(), seed());
133        assert_eq!(kp.ltpk().to_vec(), h(RFC8032_PUBLIC));
134    }
135
136    #[test]
137    fn sign_matches_rfc8032_signature() {
138        let kp = ControllerKeypair::from_seed("controller".to_string(), seed());
139        let sig = kp.sign(&h(RFC8032_MESSAGE));
140        assert_eq!(sig.to_vec(), h(RFC8032_SIGNATURE));
141    }
142
143    #[test]
144    fn verify_accepts_rfc8032_signature() {
145        let ltpk: [u8; 32] = h(RFC8032_PUBLIC).try_into().unwrap();
146        let sig: [u8; 64] = h(RFC8032_SIGNATURE).try_into().unwrap();
147        assert!(verify_ed25519(&ltpk, &h(RFC8032_MESSAGE), &sig).is_ok());
148    }
149
150    #[test]
151    fn verify_rejects_flipped_signature_bit() {
152        let ltpk: [u8; 32] = h(RFC8032_PUBLIC).try_into().unwrap();
153        let mut sig: [u8; 64] = h(RFC8032_SIGNATURE).try_into().unwrap();
154        sig[0] ^= 0x01;
155        assert!(matches!(
156            verify_ed25519(&ltpk, &h(RFC8032_MESSAGE), &sig),
157            Err(CryptoError::Signature)
158        ));
159    }
160
161    #[test]
162    fn verify_rejects_tampered_message() {
163        let ltpk: [u8; 32] = h(RFC8032_PUBLIC).try_into().unwrap();
164        let sig: [u8; 64] = h(RFC8032_SIGNATURE).try_into().unwrap();
165        assert!(matches!(
166            verify_ed25519(&ltpk, b"different message", &sig),
167            Err(CryptoError::Signature)
168        ));
169    }
170
171    #[test]
172    fn generate_then_sign_verify_roundtrips() {
173        let kp = ControllerKeypair::generate("aa:bb:cc".to_string());
174        assert_eq!(kp.ltpk().len(), 32);
175        let msg = b"pair-setup signing material";
176        let sig = kp.sign(msg);
177        assert!(verify_ed25519(&kp.ltpk(), msg, &sig).is_ok());
178    }
179}