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;