Skip to main content

pim_crypto/
mesh.rs

1//! Mesh-membership secret derivation.
2//!
3//! A "private mesh" is a group of nodes that share a passphrase. The
4//! passphrase is stretched once at daemon startup via Argon2id and the
5//! resulting 32-byte master is split via HKDF-SHA256 into three
6//! purpose-bound sub-keys:
7//!
8//! * `discovery_key` — AES-256-GCM key for encrypting UDP/Wi-Fi-Direct
9//!   discovery advertisements. Non-members can't decrypt the ad and
10//!   never learn the mesh exists on the wire.
11//! * `handshake_key` — mixed into the HKDF IKM of the per-session
12//!   handshake (see [`crate::handshake`]). A peer without the right
13//!   `handshake_key` derives a different session key, fails the
14//!   transcript HMAC, and is rejected before any traffic flows.
15//! * `fingerprint` — 8-byte display value (NOT a secret) used by the
16//!   UI/CLI to identify the mesh; derived from the same master so two
17//!   nodes that share the passphrase show the same fingerprint.
18//!
19//! Open mesh = absence of `MeshSecret`. Private mesh = `MeshSecret`
20//! present and matching across all nodes.
21
22use argon2::{Algorithm, Argon2, Params, Version};
23use hkdf::Hkdf;
24use hmac::{Hmac, Mac};
25use sha2::Sha256;
26
27type HmacSha256 = Hmac<Sha256>;
28
29/// Argon2id parameters used to stretch the passphrase.
30///
31/// Defaults aim for ~100 ms on a modern desktop and ~600 ms on a
32/// Raspberry Pi 4. Cost is paid **once at daemon startup**, not per
33/// handshake, so this can be conservative.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct MeshKdfParams {
36    /// Memory cost in KiB. Default 65536 (64 MiB).
37    pub m_cost_kib: u32,
38    /// Number of iterations. Default 3.
39    pub t_cost: u32,
40    /// Parallelism. Default 1 (single-threaded; multi-threaded Argon2
41    /// requires extra plumbing and the startup cost target is met
42    /// without it).
43    pub p_cost: u32,
44}
45
46impl Default for MeshKdfParams {
47    fn default() -> Self {
48        Self {
49            m_cost_kib: 65536,
50            t_cost: 3,
51            p_cost: 1,
52        }
53    }
54}
55
56impl MeshKdfParams {
57    fn to_argon2_params(self) -> Result<Params, MeshKdfError> {
58        Params::new(self.m_cost_kib, self.t_cost, self.p_cost, Some(32))
59            .map_err(|e| MeshKdfError::InvalidParams(format!("{e}")))
60    }
61}
62
63/// Derived secrets for a private mesh.
64///
65/// Construct with [`MeshSecret::derive`]. The master Argon2id output
66/// is consumed during construction and never exposed outside this
67/// type.
68#[derive(Clone)]
69pub struct MeshSecret {
70    discovery_key: [u8; 32],
71    handshake_key: [u8; 32],
72    fingerprint: [u8; 8],
73}
74
75impl MeshSecret {
76    /// Derive a `MeshSecret` from a UTF-8 passphrase and an optional
77    /// mesh label.
78    ///
79    /// `mesh_id` is mixed into the Argon2 salt to provide cryptographic
80    /// separation between meshes that happen to pick the same
81    /// passphrase ("home", "office"). Pass `None` (or `Some("")`) for
82    /// no mesh label — both produce the same secret.
83    pub fn derive(
84        passphrase: &str,
85        mesh_id: Option<&str>,
86        params: MeshKdfParams,
87    ) -> Result<Self, MeshKdfError> {
88        if passphrase.is_empty() {
89            return Err(MeshKdfError::EmptyPassphrase);
90        }
91
92        // Argon2id salt: fixed domain-separation prefix + mesh_id (if any).
93        // The Argon2 crate enforces salt.len() >= 8, which the prefix satisfies.
94        let mut salt = Vec::with_capacity(32);
95        salt.extend_from_slice(b"pim-mesh-v1");
96        if let Some(id) = mesh_id {
97            salt.extend_from_slice(id.as_bytes());
98        }
99
100        let argon2 = Argon2::new(
101            Algorithm::Argon2id,
102            Version::V0x13,
103            params.to_argon2_params()?,
104        );
105        let mut master = [0u8; 32];
106        argon2
107            .hash_password_into(passphrase.as_bytes(), &salt, &mut master)
108            .map_err(|e| MeshKdfError::Argon2(format!("{e}")))?;
109
110        // Split via HKDF-SHA256 into purpose-bound sub-keys.
111        let hk = Hkdf::<Sha256>::new(None, &master);
112
113        let mut discovery_key = [0u8; 32];
114        let mut handshake_key = [0u8; 32];
115        let mut fingerprint = [0u8; 8];
116        hk.expand(b"pim-disco-v1", &mut discovery_key)
117            .map_err(|e| MeshKdfError::Hkdf(format!("{e}")))?;
118        hk.expand(b"pim-handshake-v1", &mut handshake_key)
119            .map_err(|e| MeshKdfError::Hkdf(format!("{e}")))?;
120        hk.expand(b"pim-fingerprint-v1", &mut fingerprint)
121            .map_err(|e| MeshKdfError::Hkdf(format!("{e}")))?;
122
123        // Master is no longer needed; let it drop on return.
124        Ok(Self {
125            discovery_key,
126            handshake_key,
127            fingerprint,
128        })
129    }
130
131    /// AES-256-GCM key for encrypted discovery advertisements.
132    pub fn discovery_key(&self) -> &[u8; 32] {
133        &self.discovery_key
134    }
135
136    /// 32-byte secret mixed into the per-session handshake HKDF IKM.
137    pub fn handshake_key(&self) -> &[u8; 32] {
138        &self.handshake_key
139    }
140
141    /// 8-byte non-secret display value used by UI/CLI.
142    pub fn fingerprint(&self) -> &[u8; 8] {
143        &self.fingerprint
144    }
145
146    /// Lowercase hex of [`Self::fingerprint`], suitable for logging.
147    pub fn fingerprint_hex(&self) -> String {
148        let mut out = String::with_capacity(16);
149        for b in self.fingerprint {
150            out.push_str(&format!("{b:02x}"));
151        }
152        out
153    }
154}
155
156impl std::fmt::Debug for MeshSecret {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        // Never log key material — only the public fingerprint.
159        f.debug_struct("MeshSecret")
160            .field("fingerprint", &self.fingerprint_hex())
161            .finish_non_exhaustive()
162    }
163}
164
165/// Compute the RFCOMM Hello mesh-membership tag.
166///
167/// Used by the Bluetooth RFCOMM session-level handshake (Hello /
168/// HelloAck) to prove that both ends share the mesh handshake key
169/// **before** the RFCOMM channel is bridged onto loopback TCP. Without
170/// this check, a paired device with a `PIM-*` name but no mesh
171/// membership could force us to spin up a TCP-bridge handshake task
172/// (Ed25519 work, 10s timeout) that's certain to fail.
173///
174/// The tag is `HMAC-SHA256(handshake_key, node_id_hex)`. It binds to
175/// the *sender's* node id, so a captured tag from a real PIM peer is
176/// only useful for replaying that specific NodeId — and the inner
177/// Ed25519 + handshake-bound session key derivation in `pim-transport`
178/// will reject the replay because the attacker can't sign with the
179/// real node's private key.
180///
181/// Returns the raw 32-byte HMAC. Encode as lowercase hex on the wire
182/// (the JSON-based RFCOMM Hello envelope can't carry binary bytes).
183pub fn compute_rfcomm_hello_tag(handshake_key: &[u8; 32], node_id_hex: &str) -> [u8; 32] {
184    let mut mac = HmacSha256::new_from_slice(handshake_key).expect("HMAC accepts any key size");
185    mac.update(node_id_hex.as_bytes());
186    let result = mac.finalize().into_bytes();
187    let mut out = [0u8; 32];
188    out.copy_from_slice(&result);
189    out
190}
191
192/// Errors returned during mesh-secret derivation.
193#[derive(Debug, thiserror::Error)]
194pub enum MeshKdfError {
195    /// Passphrase string was empty.
196    #[error("mesh passphrase must not be empty")]
197    EmptyPassphrase,
198    /// Argon2id parameters were rejected by the implementation.
199    #[error("invalid argon2 parameters: {0}")]
200    InvalidParams(String),
201    /// Argon2id execution failed.
202    #[error("argon2 failure: {0}")]
203    Argon2(String),
204    /// HKDF expansion failed (should be unreachable for our fixed sizes).
205    #[error("hkdf failure: {0}")]
206    Hkdf(String),
207}
208
209#[cfg(test)]
210mod tests;