core_ipc/
noise_keys.rs

1//! Key management and persistence for Noise IK keypairs.
2//!
3//! Handles generation, filesystem I/O, permissions, and tamper-detection
4//! checksums for both the bus server keypair and per-daemon keypairs.
5//!
6//! Key storage layout under `$XDG_RUNTIME_DIR/pds/`:
7//! - `bus.pub` (0644) — bus server public key, read by connecting daemons
8//! - `bus.key` (0600) — bus server private key
9//! - `bus.checksum` — BLAKE3 integrity checksum
10//! - `keys/<daemon>.pub` (0644) — per-daemon public key
11//! - `keys/<daemon>.key` (0600) — per-daemon private key
12//! - `keys/<daemon>.checksum` — per-daemon integrity checksum
13
14use std::path::{Path, PathBuf};
15
16/// Compute an integrity-detection checksum for a keypair.
17///
18/// Uses BLAKE3 keyed hash with the public key as the 32-byte key and
19/// the private key as the data. Detects partial corruption or partial tampering
20/// (e.g., private key replaced but checksum file untouched). Does NOT prevent
21/// an attacker with full filesystem write access from replacing all three files
22/// (private key, public key, checksum) with a self-consistent set — that requires
23/// a root-of-trust outside the filesystem (e.g., TPM-backed attestation).
24fn keypair_checksum(public_key: &[u8; 32], private_key: &[u8]) -> [u8; 32] {
25    *blake3::keyed_hash(public_key, private_key).as_bytes()
26}
27
28/// Generate a new X25519 static keypair for Noise IK.
29///
30/// Called once at daemon startup. The keypair is ephemeral to the process lifetime
31/// for connecting daemons, or persisted to `$XDG_RUNTIME_DIR/pds/` for the bus server.
32///
33/// # Errors
34///
35/// Returns an error if the crypto resolver fails.
36pub fn generate_keypair() -> core_types::Result<ZeroizingKeypair> {
37    let builder = snow::Builder::new(
38        crate::noise::NOISE_PARAMS
39            .parse()
40            .map_err(|e| core_types::Error::Platform(format!("invalid Noise params: {e}")))?,
41    );
42    let keypair = builder
43        .generate_keypair()
44        .map_err(|e| core_types::Error::Platform(format!("keypair generation failed: {e}")))?;
45    Ok(ZeroizingKeypair::new(keypair))
46}
47
48/// Zeroize-on-drop wrapper for `snow::Keypair`.
49///
50/// `snow::Keypair` has no `Drop` impl, so the private key persists in freed
51/// memory if not explicitly zeroized. This wrapper guarantees zeroization on
52/// drop, including during panics (unwind calls `Drop`).
53///
54/// All code that constructs or receives a `snow::Keypair` should use this
55/// wrapper instead. The private key is accessible via `private()` for
56/// passing to `snow::Builder::local_private_key()`.
57pub struct ZeroizingKeypair {
58    inner: snow::Keypair,
59}
60
61impl ZeroizingKeypair {
62    /// Wrap a `snow::Keypair`, taking ownership.
63    #[must_use]
64    pub fn new(keypair: snow::Keypair) -> Self {
65        Self { inner: keypair }
66    }
67
68    /// Access the public key (32 bytes).
69    #[must_use]
70    pub fn public(&self) -> &[u8] {
71        &self.inner.public
72    }
73
74    /// Access the private key (32 bytes). Use only for `snow::Builder` calls.
75    #[must_use]
76    pub fn private(&self) -> &[u8] {
77        &self.inner.private
78    }
79
80    /// Borrow the inner `snow::Keypair` for APIs that require `&snow::Keypair`.
81    #[must_use]
82    pub fn as_inner(&self) -> &snow::Keypair {
83        &self.inner
84    }
85
86    /// Consume the wrapper and return the inner `snow::Keypair`.
87    ///
88    /// The caller takes responsibility for zeroizing the private key.
89    /// Use only when transferring ownership to an API that requires `snow::Keypair`
90    /// (e.g., `BusServer::bind`).
91    #[must_use]
92    pub fn into_inner(mut self) -> snow::Keypair {
93        let private = std::mem::take(&mut self.inner.private);
94        let public = std::mem::take(&mut self.inner.public);
95        snow::Keypair { private, public }
96    }
97}
98
99impl Drop for ZeroizingKeypair {
100    fn drop(&mut self) {
101        zeroize::Zeroize::zeroize(&mut self.inner.private);
102    }
103}
104
105impl From<snow::Keypair> for ZeroizingKeypair {
106    fn from(keypair: snow::Keypair) -> Self {
107        Self::new(keypair)
108    }
109}
110
111/// Override for the runtime directory path. Set by tests to avoid `set_var`
112/// race conditions (P7). Production code never sets this.
113static RUNTIME_DIR_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
114
115/// Set the runtime directory override. Intended for testing only.
116/// Avoids `unsafe { set_var(...) }` which races with other threads reading env.
117#[doc(hidden)]
118pub fn set_runtime_dir_override(path: PathBuf) {
119    *RUNTIME_DIR_OVERRIDE.lock().unwrap() = Some(path);
120}
121
122/// Resolve the PDS runtime directory (`$XDG_RUNTIME_DIR/pds/`).
123fn runtime_dir() -> core_types::Result<PathBuf> {
124    if let Some(ref override_dir) = *RUNTIME_DIR_OVERRIDE.lock().unwrap() {
125        return Ok(override_dir.clone());
126    }
127    let runtime = std::env::var("XDG_RUNTIME_DIR")
128        .map_err(|_| core_types::Error::Platform("XDG_RUNTIME_DIR is not set".into()))?;
129    Ok(PathBuf::from(runtime).join("pds"))
130}
131
132/// Write the bus server's static keypair to the runtime directory.
133///
134/// - `bus.pub` (32 bytes, world-readable) — read by connecting daemons
135/// - `bus.key` (32 bytes, mode 0600) — private key for the bus server
136///
137/// # Errors
138///
139/// Returns an error if directory creation or file I/O fails.
140pub async fn write_bus_keypair(keypair: &snow::Keypair) -> core_types::Result<()> {
141    let dir = runtime_dir()?;
142    tokio::fs::create_dir_all(&dir).await.map_err(|e| {
143        core_types::Error::Platform(format!(
144            "failed to create runtime dir {}: {e}",
145            dir.display()
146        ))
147    })?;
148
149    let pub_path = dir.join("bus.pub");
150    let key_path = dir.join("bus.key");
151
152    tokio::fs::write(&pub_path, &keypair.public)
153        .await
154        .map_err(|e| core_types::Error::Platform(format!("failed to write bus.pub: {e}")))?;
155    #[cfg(unix)]
156    {
157        use std::os::unix::fs::PermissionsExt;
158        tokio::fs::set_permissions(&pub_path, std::fs::Permissions::from_mode(0o644))
159            .await
160            .map_err(|e| {
161                core_types::Error::Platform(format!("failed to set bus.pub permissions: {e}"))
162            })?;
163    }
164
165    // Write private key atomically: write to temp file with 0600 perms, then rename.
166    // This prevents a window where the key file exists with default (permissive) permissions.
167    #[cfg(unix)]
168    {
169        use std::io::Write;
170        use std::os::unix::fs::OpenOptionsExt;
171
172        let tmp_key_path = dir.join("bus.key.tmp");
173        // Blocking file ops for atomic write with mode — wrapped in spawn_blocking
174        // to avoid blocking the tokio runtime.
175        let private_key = zeroize::Zeroizing::new(keypair.private.clone());
176        let tmp_path = tmp_key_path.clone();
177        let final_path = key_path.clone();
178        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
179            let mut f = std::fs::OpenOptions::new()
180                .write(true)
181                .create(true)
182                .truncate(true)
183                .mode(0o600)
184                .open(&tmp_path)?;
185            f.write_all(&private_key)?;
186            f.sync_all()?;
187            std::fs::rename(&tmp_path, &final_path)?;
188            Ok(())
189        })
190        .await
191        .map_err(|e| core_types::Error::Platform(format!("bus.key write task failed: {e}")))?
192        .map_err(|e| core_types::Error::Platform(format!("failed to write bus.key: {e}")))?;
193    }
194
195    #[cfg(not(unix))]
196    {
197        compile_error!(
198            "bus.key writing requires Unix file permissions (mode 0600). Non-Unix platforms are not supported in MVP."
199        );
200    }
201
202    // Write tamper-detection checksum.
203    {
204        let pub_array: [u8; 32] =
205            keypair.public.clone().try_into().map_err(|_| {
206                core_types::Error::Platform("bus public key is not 32 bytes".into())
207            })?;
208        let checksum = keypair_checksum(&pub_array, &keypair.private);
209        let checksum_path = dir.join("bus.checksum");
210        tokio::fs::write(&checksum_path, checksum)
211            .await
212            .map_err(|e| {
213                core_types::Error::Platform(format!("failed to write bus.checksum: {e}"))
214            })?;
215    }
216
217    tracing::info!(
218        pub_path = %pub_path.display(),
219        key_path = %key_path.display(),
220        "bus keypair written"
221    );
222    Ok(())
223}
224
225/// Directory for per-daemon keypair files.
226fn keys_dir() -> core_types::Result<PathBuf> {
227    Ok(runtime_dir()?.join("keys"))
228}
229
230/// Create the per-daemon keys directory if it does not exist.
231///
232/// # Errors
233///
234/// Returns an error if the directory cannot be created.
235pub async fn create_keys_dir() -> core_types::Result<()> {
236    let dir = keys_dir()?;
237    tokio::fs::create_dir_all(&dir).await.map_err(|e| {
238        core_types::Error::Platform(format!("failed to create keys dir {}: {e}", dir.display()))
239    })?;
240    // Restrict to owner-only (0700) — create_dir_all inherits umask
241    // (typically 0022 → 0755), which would let any local user enumerate daemons.
242    #[cfg(unix)]
243    {
244        use std::os::unix::fs::PermissionsExt;
245        tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
246            .await
247            .map_err(|e| {
248                core_types::Error::Platform(format!(
249                    "failed to set keys dir permissions on {}: {e}",
250                    dir.display()
251                ))
252            })?;
253    }
254    Ok(())
255}
256
257/// Write a daemon's keypair to disk. Private key gets 0600, public key gets 0644.
258///
259/// Uses write-to-temp-then-rename for atomicity, same pattern as `write_bus_keypair()`.
260///
261/// # Errors
262///
263/// Returns an error if writing the keypair files fails.
264pub async fn write_daemon_keypair(
265    daemon_name: &str,
266    keypair: &snow::Keypair,
267) -> core_types::Result<()> {
268    let dir = keys_dir()?;
269
270    let pub_path = dir.join(format!("{daemon_name}.pub"));
271    let key_path = dir.join(format!("{daemon_name}.key"));
272
273    // Public key: world-readable (explicit 0644).
274    tokio::fs::write(&pub_path, &keypair.public)
275        .await
276        .map_err(|e| {
277            core_types::Error::Platform(format!("failed to write {daemon_name}.pub: {e}"))
278        })?;
279    #[cfg(unix)]
280    {
281        use std::os::unix::fs::PermissionsExt;
282        tokio::fs::set_permissions(&pub_path, std::fs::Permissions::from_mode(0o644))
283            .await
284            .map_err(|e| {
285                core_types::Error::Platform(format!(
286                    "failed to set {daemon_name}.pub permissions: {e}"
287                ))
288            })?;
289    }
290
291    // Private key: atomic write with 0600 perms.
292    #[cfg(unix)]
293    {
294        use std::io::Write;
295        use std::os::unix::fs::OpenOptionsExt;
296
297        let tmp_path = dir.join(format!("{daemon_name}.key.tmp"));
298        let private_key = zeroize::Zeroizing::new(keypair.private.clone());
299        let tmp = tmp_path.clone();
300        let final_path = key_path.clone();
301        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
302            let mut f = std::fs::OpenOptions::new()
303                .write(true)
304                .create(true)
305                .truncate(true)
306                .mode(0o600)
307                .open(&tmp)?;
308            f.write_all(&private_key)?;
309            f.sync_all()?;
310            std::fs::rename(&tmp, &final_path)?;
311            Ok(())
312        })
313        .await
314        .map_err(|e| {
315            core_types::Error::Platform(format!("{daemon_name}.key write task failed: {e}"))
316        })?
317        .map_err(|e| {
318            core_types::Error::Platform(format!("failed to write {daemon_name}.key: {e}"))
319        })?;
320    }
321
322    #[cfg(not(unix))]
323    {
324        compile_error!("daemon keypair writing requires Unix file permissions (mode 0600)");
325    }
326
327    // Write tamper-detection checksum.
328    {
329        let pub_array: [u8; 32] = keypair.public.clone().try_into().map_err(|_| {
330            core_types::Error::Platform(format!("{daemon_name} public key is not 32 bytes"))
331        })?;
332        let checksum = keypair_checksum(&pub_array, &keypair.private);
333        let checksum_path = dir.join(format!("{daemon_name}.checksum"));
334        tokio::fs::write(&checksum_path, checksum)
335            .await
336            .map_err(|e| {
337                core_types::Error::Platform(format!("failed to write {daemon_name}.checksum: {e}"))
338            })?;
339    }
340
341    tracing::debug!(
342        daemon = daemon_name,
343        pub_path = %pub_path.display(),
344        key_path = %key_path.display(),
345        "daemon keypair written"
346    );
347    Ok(())
348}
349
350/// Read a daemon's keypair from disk. Private key wrapped in `Zeroizing`.
351///
352/// # Errors
353///
354/// Returns an error if the keypair files cannot be read or have invalid size.
355pub async fn read_daemon_keypair(
356    daemon_name: &str,
357) -> core_types::Result<(zeroize::Zeroizing<Vec<u8>>, [u8; 32])> {
358    let dir = keys_dir()?;
359
360    let key_path = dir.join(format!("{daemon_name}.key"));
361    let pub_path = dir.join(format!("{daemon_name}.pub"));
362
363    let private_bytes = tokio::fs::read(&key_path).await.map_err(|e| {
364        core_types::Error::Platform(format!(
365            "failed to read {daemon_name}.key at {}: {e}",
366            key_path.display()
367        ))
368    })?;
369
370    let public_bytes = tokio::fs::read(&pub_path).await.map_err(|e| {
371        core_types::Error::Platform(format!(
372            "failed to read {daemon_name}.pub at {}: {e}",
373            pub_path.display()
374        ))
375    })?;
376
377    let public_key: [u8; 32] = public_bytes.try_into().map_err(|v: Vec<u8>| {
378        core_types::Error::Platform(format!(
379            "{daemon_name}.pub has wrong size: expected 32 bytes, got {}",
380            v.len()
381        ))
382    })?;
383
384    // Tamper detection.
385    let checksum_path = dir.join(format!("{daemon_name}.checksum"));
386    match tokio::fs::read(&checksum_path).await {
387        Ok(stored_checksum) => {
388            let expected = keypair_checksum(&public_key, &private_bytes);
389            if stored_checksum.len() != 32 || stored_checksum[..] != expected[..] {
390                return Err(core_types::Error::Platform(format!(
391                    "TAMPER DETECTED: {daemon_name} keypair checksum mismatch. \
392                     The private key or public key file may have been modified. \
393                     Delete $XDG_RUNTIME_DIR/pds/keys/{daemon_name}.* and restart daemon-profile."
394                )));
395            }
396            tracing::debug!(daemon = daemon_name, "keypair integrity verified");
397        }
398        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
399            // Backward compatibility: no checksum file from older installation.
400            tracing::warn!(
401                daemon = daemon_name,
402                "no checksum file found — keypair integrity cannot be verified. \
403                 Restart daemon-profile to generate checksums."
404            );
405        }
406        Err(e) => {
407            return Err(core_types::Error::Platform(format!(
408                "failed to read {daemon_name}.checksum: {e}"
409            )));
410        }
411    }
412
413    Ok((zeroize::Zeroizing::new(private_bytes), public_key))
414}
415
416/// Read only a daemon's public key from disk.
417///
418/// # Errors
419///
420/// Returns an error if the public key file cannot be read or has invalid size.
421pub async fn read_daemon_public_key(daemon_name: &str) -> core_types::Result<[u8; 32]> {
422    let dir = keys_dir()?;
423    let pub_path = dir.join(format!("{daemon_name}.pub"));
424    let bytes = tokio::fs::read(&pub_path).await.map_err(|e| {
425        core_types::Error::Platform(format!(
426            "failed to read {daemon_name}.pub at {}: {e}",
427            pub_path.display()
428        ))
429    })?;
430    let key: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
431        core_types::Error::Platform(format!(
432            "{daemon_name}.pub has wrong size: expected 32 bytes, got {}",
433            v.len()
434        ))
435    })?;
436    Ok(key)
437}
438
439/// Read the bus server's public key from the runtime directory.
440///
441/// Connecting daemons call this before initiating the Noise IK handshake
442/// (the "K" in IK — responder's static key is Known to the initiator).
443///
444/// # Errors
445///
446/// Returns an error if the file does not exist or is not exactly 32 bytes.
447pub async fn read_bus_public_key() -> core_types::Result<[u8; 32]> {
448    read_bus_public_key_from(&runtime_dir()?).await
449}
450
451/// Read the bus server's public key from a specific directory.
452///
453/// Allows callers to specify a custom path (useful for testing).
454///
455/// # Errors
456///
457/// Returns an error if the file does not exist or is not exactly 32 bytes.
458pub async fn read_bus_public_key_from(dir: &Path) -> core_types::Result<[u8; 32]> {
459    let pub_path = dir.join("bus.pub");
460    let bytes = tokio::fs::read(&pub_path).await.map_err(|e| {
461        core_types::Error::Platform(format!(
462            "failed to read bus.pub at {}: {e}",
463            pub_path.display()
464        ))
465    })?;
466    let key: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
467        core_types::Error::Platform(format!(
468            "bus.pub has wrong size: expected 32 bytes, got {}",
469            v.len()
470        ))
471    })?;
472    Ok(key)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn keypair_checksum_consistency() {
481        let kp = generate_keypair().unwrap();
482        let pub_array: [u8; 32] = kp.public().try_into().unwrap();
483        let checksum1 = keypair_checksum(&pub_array, kp.private());
484        let checksum2 = keypair_checksum(&pub_array, kp.private());
485        assert_eq!(checksum1, checksum2);
486    }
487
488    #[test]
489    fn keypair_checksum_detects_tampering() {
490        let kp = generate_keypair().unwrap();
491        let pub_array: [u8; 32] = kp.public().try_into().unwrap();
492        let checksum = keypair_checksum(&pub_array, kp.private());
493
494        // Tampered private key produces different checksum.
495        let mut tampered = kp.private().to_vec();
496        tampered[0] ^= 0xFF;
497        let tampered_checksum = keypair_checksum(&pub_array, &tampered);
498        assert_ne!(checksum, tampered_checksum);
499    }
500
501    #[test]
502    fn generate_keypair_produces_32_byte_keys() {
503        let kp = generate_keypair().unwrap();
504        assert_eq!(kp.private().len(), 32);
505        assert_eq!(kp.public().len(), 32);
506    }
507
508    // SECURITY INVARIANT: ZeroizingKeypair::into_inner must leave the wrapper's
509    // private key zeroed so that the original allocation does not retain key material
510    // after ownership transfer.
511    #[test]
512    fn zeroizing_keypair_into_inner_zeroes_source() {
513        let kp = generate_keypair().unwrap();
514        let private_copy = kp.private().to_vec();
515        assert!(
516            !private_copy.iter().all(|&b| b == 0),
517            "generated key must be non-zero"
518        );
519
520        let extracted = kp.into_inner();
521        // The extracted keypair should have the original private key.
522        assert_eq!(extracted.private, private_copy);
523        // After into_inner, the wrapper's field was mem::take'd (zeroed Vec).
524        // We can't inspect it post-move, but into_inner's implementation uses
525        // mem::take which replaces with empty Vec — verified by code review.
526        // This test validates the round-trip: generated key is non-zero,
527        // extraction preserves key material for the caller.
528    }
529
530    // NOTE: daemon keypair persistence tests live in
531    // core-ipc/tests/daemon_keypair.rs (uses set_runtime_dir_override)
532    // (unsafe since Rust 2024) and the crate uses `#![forbid(unsafe_code)]`.
533}