freenet 0.2.67

Freenet core software
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
use std::path::Path;

use aes_gcm::KeyInit;
use chacha20poly1305::{XChaCha20Poly1305, XNonce, aead::OsRng};
use zeroize::Zeroize;

use super::*;

const NONCE_SIZE: usize = 24;
pub(super) const CIPHER_SIZE: usize = 32;

/// Filename (relative to `secrets_dir`) of the auto-persisted per-node
/// delegate cipher introduced in freenet-core PR after the removal of
/// `DelegateRequest::DEFAULT_CIPHER` from freenet-stdlib 0.8.0. Nodes
/// generate this on first start (if no `--cipher` flag is supplied) and
/// reuse it across restarts.
pub(crate) const DELEGATE_CIPHER_FILENAME: &str = "delegate_cipher";

/// Historical `DelegateRequest::DEFAULT_NONCE` value, retained in core
/// purely for **read-side legacy decrypt** of on-disk delegate secret
/// files that pre-date the per-write-nonce format (freenet-core PR #4143).
///
/// New writes never use this value — every `store_secret` generates a
/// fresh random 24-byte nonce via `OsRng` and persists it inline with
/// the ciphertext under the version-prefixed format. This constant
/// exists only to populate `Secrets::nonce` so the existing
/// `Encryption::legacy_nonce` fallback in `SecretsStore::get_secret`
/// can still decrypt pre-#4143 files written under the old default
/// fallback path.
///
/// Removed from `freenet-stdlib` public API in 0.8.0 because exposing
/// it as a public const was what allowed default-configured nodes to
/// encrypt under a world-known nonce.
pub(crate) const LEGACY_DEFAULT_NONCE: [u8; 24] = [
    57, 18, 79, 116, 63, 134, 93, 39, 208, 161, 156, 229, 222, 247, 111, 79, 210, 126, 127, 55,
    224, 150, 139, 80,
];

/// Historical `DelegateRequest::DEFAULT_CIPHER` value, retained in core
/// purely for **read-side legacy decrypt**. See [`LEGACY_DEFAULT_NONCE`].
///
/// Tests in this module use this constant directly to construct legacy
/// `Secrets` snapshots; production code never seeds `Secrets::cipher`
/// from it — `SecretArgs::build` auto-generates a fresh cipher per node
/// and persists it to `secrets_dir/delegate_cipher`.
pub(crate) const LEGACY_DEFAULT_CIPHER: [u8; 32] = [
    0, 24, 22, 150, 112, 207, 24, 65, 182, 161, 169, 227, 66, 182, 237, 215, 206, 164, 58, 161, 64,
    108, 157, 195, 0, 0, 0, 0, 0, 0, 0, 0,
];

/// Generate a fresh 32-byte XChaCha20-Poly1305 key via `OsRng`.
///
/// `OsRng` is the documented exception to the project-wide
/// `.claude/rules/code-style.md` ban on `rand::thread_rng()` /
/// `rand::random()` in `crates/core/`: cryptographic key material MUST
/// come from the OS entropy pool (e.g. `/dev/urandom`), not from a
/// deterministic simulation RNG. This call runs at node startup, before
/// any `TimeSource` / `GlobalRng` simulation harness would be in scope.
fn generate_cipher_key() -> [u8; CIPHER_SIZE] {
    let key = XChaCha20Poly1305::generate_key(&mut OsRng);
    let mut out = [0u8; CIPHER_SIZE];
    out.copy_from_slice(key.as_slice());
    out
}

/// Persist a cipher key to disk, creating the file atomically with
/// 0o600 permissions on Unix (no window where another user could open
/// the file at a more permissive mode).
///
/// Uses `OpenOptions::create_new(true)` so the call fails with
/// `AlreadyExists` if the file appeared between the caller's existence
/// check and this write — preventing a TOCTOU where two concurrent
/// freenet processes starting against the same `secrets_dir` overwrite
/// each other's cipher.
fn save_cipher_new(path: &Path, key: &[u8; CIPHER_SIZE]) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut opts = std::fs::OpenOptions::new();
    opts.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        // Atomic 0o600 — no window between create and chmod where the
        // file is readable under the process umask.
        opts.mode(0o600);
    }
    let mut file = opts.open(path)?;
    file.write_all(key)?;
    file.sync_all()?;
    Ok(())
}

impl ConfigArgs {
    pub(super) fn read_secrets(
        path_to_key: Option<PathBuf>,
        path_to_nonce: Option<PathBuf>,
        path_to_cipher: Option<PathBuf>,
    ) -> std::io::Result<Secrets> {
        let transport_keypair = if let Some(ref path_to_key) = path_to_key {
            read_transport_keypair(path_to_key)?
        } else {
            TransportKeypair::new()
        };
        // Nonces became per-write in PR #4143 — the file-based nonce is no
        // longer used for encryption. If the operator still has an explicit
        // `--nonce` path on their config, honor the file content for legacy
        // decrypt; otherwise use the historical default so pre-#4143 on-disk
        // files written under the old fallback path remain readable.
        let nonce = if let Some(ref path_to_nonce) = path_to_nonce {
            tracing::warn!(
                "`nonce` config is deprecated since per-write nonces landed; the file at \
                 {path:?} is used only as a legacy-decrypt nonce for pre-existing secrets.",
                path = path_to_nonce
            );
            read_nonce(path_to_nonce)?
        } else {
            LEGACY_DEFAULT_NONCE
        };
        // No `--cipher` path → caller is using the legacy "read_secrets"
        // entry that does not know about secrets_dir; preserve legacy
        // semantics by populating with the historical default so existing
        // ciphertexts remain decryptable. New nodes go through
        // `SecretArgs::build`, which auto-generates a fresh cipher.
        let cipher = if let Some(ref path_to_cipher) = path_to_cipher {
            read_cipher(path_to_cipher)?
        } else {
            LEGACY_DEFAULT_CIPHER
        };

        Ok(Secrets {
            transport_keypair,
            transport_keypair_path: path_to_key,
            nonce,
            nonce_path: path_to_nonce,
            cipher,
            cipher_path: path_to_cipher,
        })
    }
}

#[derive(Debug, Default, Clone, clap::Parser, serde::Serialize, serde::Deserialize)]
pub struct SecretArgs {
    /// Path to the X25519 keypair for the transport layer.
    #[clap(long, value_parser, default_value=None, env = "TRANSPORT_KEYPAIR")]
    pub transport_keypair: Option<PathBuf>,

    /// Path to the nonce file for encrypting data.
    #[clap(long, value_parser, default_value=None, env = "NONCE")]
    pub nonce: Option<PathBuf>,

    /// Path to the cipher file for encrypting data.
    #[clap(long, value_parser, default_value=None, env = "CIPHER")]
    pub cipher: Option<PathBuf>,
}

impl SecretArgs {
    pub(super) fn build(self, secrets_dir: Option<&Path>) -> std::io::Result<Secrets> {
        let (transport_keypair_path, transport_keypair) =
            if let Some(ref explicit_path) = self.transport_keypair {
                // Explicit --transport-keypair path provided: load from it
                let keypair = read_transport_keypair(explicit_path)?;
                (self.transport_keypair, keypair)
            } else if let Some(dir) = secrets_dir {
                let default_path = dir.join("transport_keypair");
                if default_path.exists() {
                    // Auto-load persisted keypair
                    tracing::info!(
                        path = %default_path.display(),
                        "Loading persisted transport keypair"
                    );
                    let keypair = read_transport_keypair(&default_path)?;
                    (Some(default_path), keypair)
                } else {
                    // Generate new keypair and persist it
                    std::fs::create_dir_all(dir)?;
                    let keypair = TransportKeypair::new();
                    keypair.save(&default_path)?;
                    tracing::info!(
                        path = %default_path.display(),
                        "Generated and saved new transport keypair"
                    );
                    (Some(default_path), keypair)
                }
            } else {
                // No secrets_dir (e.g. tests): ephemeral keypair
                (None, TransportKeypair::new())
            };
        let nonce = self.nonce.as_ref().map(read_nonce).transpose()?;
        let (nonce_path, nonce) = if let Some(nonce) = nonce {
            tracing::warn!(
                "`--nonce` / NONCE config is deprecated since per-write nonces landed; the \
                 supplied value is used only as a legacy-decrypt nonce for pre-existing secrets."
            );
            (self.nonce, nonce)
        } else {
            // Pre-#4143 on-disk files were written under the historical
            // default nonce when the operator did not supply one; keep
            // it as the legacy-decrypt fallback so upgrades stay
            // readable. New writes generate per-write random nonces.
            (None, LEGACY_DEFAULT_NONCE)
        };

        // Cipher: explicit `--cipher` path wins. Otherwise auto-generate
        // and persist under `secrets_dir/delegate_cipher`, mirroring the
        // existing `transport_keypair` auto-persist pattern. Without a
        // `secrets_dir` (in tests), fall back to an ephemeral random
        // cipher — explicitly NOT the historical default, since the
        // default was a world-known key.
        let explicit_cipher = self.cipher.as_ref().map(read_cipher).transpose()?;
        let (cipher_path, cipher) = if let Some(cipher) = explicit_cipher {
            (self.cipher, cipher)
        } else if let Some(dir) = secrets_dir {
            let default_path = dir.join(DELEGATE_CIPHER_FILENAME);
            if default_path.exists() {
                tracing::info!(
                    path = %default_path.display(),
                    "Loading persisted delegate cipher"
                );
                let cipher = read_cipher(&default_path)?;
                (Some(default_path), cipher)
            } else {
                std::fs::create_dir_all(dir)?;
                let cipher = generate_cipher_key();
                match save_cipher_new(&default_path, &cipher) {
                    Ok(()) => {
                        tracing::info!(
                            path = %default_path.display(),
                            "Generated and saved new delegate cipher"
                        );
                        (Some(default_path), cipher)
                    }
                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
                        // Race lost: another freenet process (or our own
                        // earlier startup attempt) wrote the cipher file
                        // in the window between `default_path.exists()`
                        // returning false and our `save_cipher_new`. The
                        // file is now authoritative — load it.
                        tracing::info!(
                            path = %default_path.display(),
                            "Cipher file appeared concurrently; loading the winning copy"
                        );
                        let cipher = read_cipher(&default_path)?;
                        (Some(default_path), cipher)
                    }
                    Err(e) => return Err(e),
                }
            }
        } else {
            // No secrets_dir (tests): ephemeral random cipher. Crucially
            // NOT the historical world-known default — the whole point of
            // dropping `DelegateRequest::DEFAULT_CIPHER` was to ensure no
            // code path silently encrypts under a public constant.
            (None, generate_cipher_key())
        };

        Ok(Secrets {
            transport_keypair,
            transport_keypair_path,
            nonce,
            nonce_path,
            cipher,
            cipher_path,
        })
    }

    pub(super) fn merge(&mut self, mut other: Secrets) {
        // `Secrets` is `Drop` (zeroizes cipher/nonce); we can't move
        // path fields out of `other` directly, so swap them out with
        // `mem::take` and let `other` drop with `None` placeholders.
        if self.transport_keypair.is_none() {
            self.transport_keypair = std::mem::take(&mut other.transport_keypair_path);
        }

        if self.nonce.is_none() {
            self.nonce = std::mem::take(&mut other.nonce_path);
        }

        if self.cipher.is_none() {
            self.cipher = std::mem::take(&mut other.cipher_path);
        }
    }
}

/// Persisted secrets bundle (transport keypair + the legacy
/// cipher/nonce fields kept for read-side decrypt of pre-#4140 blobs).
///
/// `Debug` is impl'd by hand so the 32-byte cipher and 24-byte nonce
/// never leak into logs; the impl renders them as `<redacted N bytes>`.
/// On drop, the cipher and nonce byte arrays are wiped via
/// `Zeroize::zeroize` so the freelist slot cannot be read back later by
/// an unrelated allocation. `TransportKeypair` carries its own
/// `ZeroizeOnDrop` on the secret-key half (`x25519_dalek::StaticSecret`),
/// so this struct does not need to wipe it explicitly.
#[derive(Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Secrets {
    #[serde(skip)]
    pub transport_keypair: TransportKeypair,
    #[serde(rename = "transport_keypair", skip_serializing_if = "Option::is_none")]
    pub transport_keypair_path: Option<PathBuf>,
    #[serde(skip)]
    pub nonce: [u8; 24],
    #[serde(rename = "nonce", skip_serializing_if = "Option::is_none")]
    pub nonce_path: Option<PathBuf>,
    #[serde(skip)]
    pub cipher: [u8; 32],
    #[serde(rename = "cipher", skip_serializing_if = "Option::is_none")]
    pub cipher_path: Option<PathBuf>,
}

impl std::fmt::Debug for Secrets {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Secrets")
            .field("transport_keypair", &"<redacted>")
            .field("transport_keypair_path", &self.transport_keypair_path)
            .field("nonce", &"<redacted 24 bytes>")
            .field("nonce_path", &self.nonce_path)
            .field("cipher", &"<redacted 32 bytes>")
            .field("cipher_path", &self.cipher_path)
            .finish()
    }
}

impl Drop for Secrets {
    fn drop(&mut self) {
        self.cipher.zeroize();
        self.nonce.zeroize();
    }
}

// Only used in tests
//
// **Non-reproducible `Default`.** This impl deliberately returns a
// different `cipher` and `transport_keypair` on each call — both fields
// are generated from fresh OS entropy. This is required by the
// production-side security contract (no code path may silently encrypt
// under a fixed, world-known key), but it breaks the typical `Default`
// reproducibility expectation: two `Secrets::default()` values are NOT
// equal, even though `Secrets: Eq`. Any test that constructs two
// `Default::default()` instances and expects them to share a cipher
// (e.g. encrypting under one and decrypting under another without a
// `register_delegate` round-trip) will fail. All current test sites
// construct exactly one `SecretsStore::new(_, Default::default(), _)`
// per test and route their crypto through the registered-cipher path,
// so the non-reproducibility is invisible to them.
#[cfg(test)]
impl Default for Secrets {
    fn default() -> Self {
        let transport_keypair = TransportKeypair::new();
        // Random cipher per test instance — explicitly NOT the historical
        // world-known constant. Production `SecretArgs::build` auto-
        // generates + persists; tests don't need persistence.
        let cipher = generate_cipher_key();
        // Pin the nonce to the legacy default so any test that hand-
        // crafts a legacy on-disk blob and then calls `SecretsStore::new`
        // with `Default::default()` exercises the same legacy-decrypt
        // path that production upgrades use.
        let nonce = LEGACY_DEFAULT_NONCE;

        Secrets {
            transport_keypair,
            transport_keypair_path: None,
            nonce,
            nonce_path: None,
            cipher,
            cipher_path: None,
        }
    }
}

impl Secrets {
    #[inline]
    pub fn nonce(&self) -> XNonce {
        self.nonce.into()
    }

    #[inline]
    pub fn cipher(&self) -> XChaCha20Poly1305 {
        XChaCha20Poly1305::new((&self.cipher).into())
    }

    #[inline]
    pub fn transport_keypair(&self) -> &TransportKeypair {
        &self.transport_keypair
    }
}

fn read_nonce(path_to_nonce: impl AsRef<Path>) -> std::io::Result<[u8; NONCE_SIZE]> {
    let path_to_nonce = path_to_nonce.as_ref();
    let mut nonce_file = File::open(path_to_nonce).map_err(|e| {
        std::io::Error::new(
            e.kind(),
            format!("Failed to open key file {}: {e}", path_to_nonce.display()),
        )
    })?;
    let mut buf = [0u8; NONCE_SIZE];
    nonce_file.read_exact(&mut buf).map_err(|e| {
        std::io::Error::new(
            e.kind(),
            format!("Failed to read key file {}: {e}", path_to_nonce.display()),
        )
    })?;

    Ok::<_, std::io::Error>(buf)
}

fn read_cipher(path_to_cipher: impl AsRef<Path>) -> std::io::Result<[u8; CIPHER_SIZE]> {
    let path_to_cipher = path_to_cipher.as_ref();
    let mut cipher_file = File::open(path_to_cipher).map_err(|e| {
        std::io::Error::new(
            e.kind(),
            format!("Failed to open key file {}: {e}", path_to_cipher.display()),
        )
    })?;
    let mut buf = [0u8; CIPHER_SIZE];
    cipher_file.read_exact(&mut buf).map_err(|e| {
        std::io::Error::new(
            e.kind(),
            format!("Failed to read key file {}: {e}", path_to_cipher.display()),
        )
    })?;

    Ok::<_, std::io::Error>(buf)
}

fn read_transport_keypair(path_to_key: impl AsRef<Path>) -> std::io::Result<TransportKeypair> {
    let path = path_to_key.as_ref();
    match TransportKeypair::load(path) {
        Ok(keypair) => Ok(keypair),
        Err(e) => {
            // Check if this looks like an old RSA PEM key
            if let Ok(content) = std::fs::read_to_string(path) {
                if content.trim().starts_with("-----BEGIN") {
                    tracing::warn!(
                        path = %path.display(),
                        "Found RSA PEM key (legacy format). Generating new X25519 keypair. \
                         The old key file will be overwritten."
                    );
                    let keypair = TransportKeypair::new();
                    keypair.save(path)?;

                    // Also update the companion public key file if it exists.
                    // Derive the public key path from the private key filename:
                    //   "gw1_private_key.pem" → "gw1_public_key.pem"
                    //   "private_key.pem" → "public_key.pem"
                    if let Some(parent) = path.parent() {
                        let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
                        let public_filename = filename.replace("private", "public");
                        let public_key_path = parent.join(&public_filename);
                        if public_key_path.exists() {
                            if let Err(e) = keypair.public().save(&public_key_path) {
                                tracing::warn!(
                                    path = %public_key_path.display(),
                                    error = %e,
                                    "Failed to update public key file"
                                );
                            } else {
                                tracing::info!(
                                    path = %public_key_path.display(),
                                    "Updated public key file to X25519 format"
                                );
                            }
                        }
                    }

                    return Ok(keypair);
                }
            }
            Err(e)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_load_from_different_files() {
        let transport_keypair = TransportKeypair::new();
        let nonce = [0u8; NONCE_SIZE];
        let cipher = [0u8; CIPHER_SIZE];

        let transport_keypair_file = tempfile::NamedTempFile::new().unwrap();
        let mut nonce_file = tempfile::NamedTempFile::new().unwrap();
        let mut cipher_file = tempfile::NamedTempFile::new().unwrap();

        // write secrets to files using hex format (new X25519 format)
        transport_keypair
            .save(transport_keypair_file.path())
            .unwrap();
        nonce_file.write_all(&nonce).unwrap();
        cipher_file.write_all(&cipher).unwrap();

        let secrets = Secrets {
            transport_keypair,
            transport_keypair_path: Some(transport_keypair_file.path().to_path_buf()),
            nonce,
            nonce_path: Some(nonce_file.path().to_path_buf()),
            cipher,
            cipher_path: Some(cipher_file.path().to_path_buf()),
        };

        let secret_args = SecretArgs {
            transport_keypair: Some(transport_keypair_file.path().to_path_buf()),
            nonce: Some(nonce_file.path().to_path_buf()),
            cipher: Some(cipher_file.path().to_path_buf()),
        };

        let loaded_secrets = secret_args.build(None).unwrap();
        assert_eq!(secrets, loaded_secrets);
    }

    /// `SecretArgs::default().build(None)` (no `--cipher`, no `secrets_dir`)
    /// MUST produce a random ephemeral cipher — NOT the historical
    /// `LEGACY_DEFAULT_CIPHER` constant. The whole point of removing
    /// `DelegateRequest::DEFAULT_CIPHER` from freenet-stdlib 0.8.0 was to
    /// guarantee that no code path silently encrypts under a public
    /// constant. This test pins that invariant.
    #[test]
    fn test_load_default_generates_random_cipher() {
        let secret_args = SecretArgs::default();
        let loaded_secrets = secret_args.build(None).unwrap();
        assert_ne!(
            LEGACY_DEFAULT_CIPHER, loaded_secrets.cipher,
            "build(None) must NOT seed the historical default cipher"
        );
        assert_ne!(
            [0u8; CIPHER_SIZE], loaded_secrets.cipher,
            "build(None) must produce a non-zero cipher"
        );
        // Two invocations must produce DIFFERENT ciphers — confirms the
        // RNG is actually being consulted per call.
        let another = SecretArgs::default().build(None).unwrap();
        assert_ne!(
            loaded_secrets.cipher, another.cipher,
            "two ephemeral builds must produce distinct random ciphers"
        );
        // Nonce stays at LEGACY_DEFAULT_NONCE (read-side fallback only).
        assert_eq!(LEGACY_DEFAULT_NONCE, loaded_secrets.nonce);
    }

    /// `SecretArgs::default().build(Some(dir))` auto-persists a cipher
    /// to `dir/delegate_cipher` on first call and reloads the same
    /// bytes on the second call. Mirrors the existing `transport_keypair`
    /// auto-persist behavior and is the upgrade path that replaces the
    /// removed `DEFAULT_CIPHER` fallback.
    #[test]
    fn test_cipher_auto_persist_and_reload() {
        let tmp_dir = tempfile::tempdir().unwrap();
        let secrets_dir = tmp_dir.path();
        let args1 = SecretArgs::default();
        let secrets1 = args1.build(Some(secrets_dir)).unwrap();

        let cipher_path = secrets_dir.join(DELEGATE_CIPHER_FILENAME);
        assert!(cipher_path.exists(), "cipher file should be created");
        assert_eq!(
            secrets1.cipher_path.as_deref(),
            Some(cipher_path.as_path()),
            "cipher_path should point at the persisted file"
        );
        assert_ne!(
            LEGACY_DEFAULT_CIPHER, secrets1.cipher,
            "auto-generated cipher must NOT equal the historical default"
        );
        // File permissions on Unix must be 0o600 (owner read/write only).
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&cipher_path)
                .unwrap()
                .permissions()
                .mode()
                & 0o777;
            assert_eq!(mode, 0o600, "cipher file must be 0o600, got {mode:o}");
        }

        let args2 = SecretArgs::default();
        let secrets2 = args2.build(Some(secrets_dir)).unwrap();
        assert_eq!(
            secrets1.cipher, secrets2.cipher,
            "second build must reload the persisted cipher"
        );
    }

    /// Passing `--cipher /path/that/does/not/exist` MUST error rather
    /// than silently fall back to any default. The whole behavioral
    /// contract of removing `DEFAULT_CIPHER` is that there is no
    /// silent fallback to a known-bad key.
    #[test]
    fn test_missing_cipher_path_is_hard_error() {
        let tmp_dir = tempfile::tempdir().unwrap();
        let missing = tmp_dir.path().join("does-not-exist");
        let args = SecretArgs {
            cipher: Some(missing),
            ..Default::default()
        };
        let err = args
            .build(None)
            .expect_err("missing cipher path must error");
        // Surface as a file-not-found IO error from read_cipher.
        assert_eq!(
            err.kind(),
            std::io::ErrorKind::NotFound,
            "expected NotFound, got {err:?}"
        );
    }

    #[test]
    fn test_keypair_auto_persist_and_reload() {
        let tmp_dir = tempfile::tempdir().unwrap();
        let secrets_dir = tmp_dir.path();

        // First build: no keypair file exists, should generate and save
        let args1 = SecretArgs::default();
        let secrets1 = args1.build(Some(secrets_dir)).unwrap();

        let keypair_path = secrets_dir.join("transport_keypair");
        assert!(keypair_path.exists(), "keypair file should be created");
        assert_eq!(
            secrets1.transport_keypair_path.as_deref(),
            Some(keypair_path.as_path())
        );

        // Second build: file exists, should load the same keypair
        let args2 = SecretArgs::default();
        let secrets2 = args2.build(Some(secrets_dir)).unwrap();

        assert_eq!(
            secrets1.transport_keypair.public(),
            secrets2.transport_keypair.public(),
            "reloaded keypair should have the same public key"
        );
        assert_eq!(
            secrets2.transport_keypair_path.as_deref(),
            Some(keypair_path.as_path()),
            "reloaded keypair should preserve the path"
        );
    }

    /// `TransportKeypair::save` (private half) MUST land at 0o600 on
    /// Unix. Before the file-permission tightening, the tmp file was
    /// created via `File::create` which inherited the process umask
    /// (typically 0o022 → 0o644, world-readable). Pin the tight mode
    /// here so a future regression that drops the `OpenOptions::mode`
    /// call would fail this test instead of silently leaking the X25519
    /// private key to other local users.
    #[cfg(unix)]
    #[test]
    fn test_transport_keypair_file_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;

        let tmp_dir = tempfile::tempdir().unwrap();
        let secrets_dir = tmp_dir.path();

        // Auto-persist path: SecretArgs::build writes via TransportKeypair::save.
        let args = SecretArgs::default();
        let secrets = args.build(Some(secrets_dir)).unwrap();
        let keypair_path = secrets
            .transport_keypair_path
            .as_deref()
            .expect("auto-persisted keypair has a path");

        let mode = std::fs::metadata(keypair_path)
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(
            mode, 0o600,
            "auto-persisted transport_keypair must be 0o600, got {mode:o}"
        );

        // Direct save path: also pin the mode for callers that hit
        // TransportKeypair::save outside SecretArgs::build (e.g. the
        // RSA-PEM legacy upgrade path in read_transport_keypair).
        let direct_path = secrets_dir.join("direct_keypair");
        TransportKeypair::new().save(&direct_path).unwrap();
        let direct_mode = std::fs::metadata(&direct_path)
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(
            direct_mode, 0o600,
            "direct TransportKeypair::save must produce 0o600, got {direct_mode:o}"
        );
    }

    #[test]
    fn test_explicit_keypair_overrides_secrets_dir() {
        let tmp_dir = tempfile::tempdir().unwrap();
        let secrets_dir = tmp_dir.path();

        // Pre-populate the default location
        let args_seed = SecretArgs::default();
        let seeded = args_seed.build(Some(secrets_dir)).unwrap();

        // Create a different keypair at an explicit path
        let explicit_file = tempfile::NamedTempFile::new().unwrap();
        let different_keypair = TransportKeypair::new();
        different_keypair.save(explicit_file.path()).unwrap();

        // Build with explicit path — should use that, not the default
        let args = SecretArgs {
            transport_keypair: Some(explicit_file.path().to_path_buf()),
            ..Default::default()
        };
        let loaded = args.build(Some(secrets_dir)).unwrap();

        assert_eq!(
            loaded.transport_keypair.public(),
            different_keypair.public()
        );
        assert_ne!(
            loaded.transport_keypair.public(),
            seeded.transport_keypair.public(),
            "explicit path should override auto-persisted keypair"
        );
    }
}