Skip to main content

greentic_operator_trust/
operator_key.rs

1//! Operator-key load/generate (C2 of `plans/next-gen-deployment.md`).
2//!
3//! Provides the Ed25519 keypair the operator uses to sign artifacts it owns
4//! (today: B10 revenue-policy DSSE; future: revision manifests, audit-log
5//! tips). Key material lives on the operator's filesystem and is created on
6//! first use; rotation is out of scope for C2 v1 — that's the Trust plan.
7//!
8//! ## Key location
9//!
10//! The path is resolved in this order:
11//!
12//! 1. `$GTC_OPERATOR_KEY_PATH` if set (caller wants a non-default location,
13//!    e.g. a vault-mounted tmpfs path or a CI fixture).
14//! 2. `~/.greentic/operator/key.pem` on POSIX/Windows.
15//! 3. Error — no home directory and no env override.
16//!
17//! Companion file: `<key.pem>.pub` (SPKI PEM). It is derived from the
18//! private key, written next to it on generation, and cross-checked on
19//! subsequent loads — if a `.pub` exists but does not match the canonical
20//! id derived from the private key, the load fails. A stale `.pub` from a
21//! prior `.pem` always indicates operator-side tampering or a partial
22//! rotation; deriving silently from the private key would mask it.
23//!
24//! ## File modes
25//!
26//! On POSIX, the private key is written with mode `0600` and the public
27//! key with mode `0644`. On platforms without `std::os::unix::fs`, the
28//! permissions fall through to the OS default.
29
30use std::path::{Path, PathBuf};
31
32use ed25519_dalek::SigningKey as Ed25519SigningKey;
33use ed25519_dalek::pkcs8::EncodePublicKey;
34use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
35use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey};
36use greentic_distributor_client::signing::{SigningError, key_id_for_public_key_pem};
37use rand::TryRngCore;
38use rand::rngs::OsRng;
39use thiserror::Error;
40use zeroize::Zeroizing;
41
42/// Override for the operator key path. When set, takes precedence over the
43/// `~/.greentic/operator/key.pem` default.
44pub const OPERATOR_KEY_PATH_ENV: &str = "GTC_OPERATOR_KEY_PATH";
45
46/// Home-directory resolution for the default key location. Mirrors the
47/// deployer's `environment::store::dirs_home` so the resolved path is
48/// identical whichever crate asks.
49#[cfg(unix)]
50fn dirs_home() -> Option<PathBuf> {
51    std::env::var_os("HOME").map(PathBuf::from)
52}
53
54#[cfg(windows)]
55fn dirs_home() -> Option<PathBuf> {
56    std::env::var_os("USERPROFILE").map(PathBuf::from)
57}
58
59#[cfg(not(any(unix, windows)))]
60fn dirs_home() -> Option<PathBuf> {
61    None
62}
63
64#[derive(Debug, Error)]
65pub enum OperatorKeyError {
66    #[error(
67        "cannot resolve operator key path: `${OPERATOR_KEY_PATH_ENV}` is unset and no home directory is available"
68    )]
69    NoHome,
70    #[error("operator key io on {path}: {source}")]
71    Io {
72        path: PathBuf,
73        #[source]
74        source: std::io::Error,
75    },
76    #[error("operator key parse: {0}")]
77    KeyDecode(String),
78    #[error("operator key derivation: {0}")]
79    Signing(#[from] SigningError),
80    #[error(
81        "operator public key `{pub_path}` is stale: id `{pub_id}` does not match private-key id `{priv_id}` — delete the `.pub` and re-run to regenerate, or restore the matching private key"
82    )]
83    StalePublicKey {
84        pub_path: PathBuf,
85        pub_id: String,
86        priv_id: String,
87    },
88    #[error("operator key entropy: {0}")]
89    Entropy(String),
90    /// The on-disk operator key has group/world-readable permissions. A
91    /// copied or restored key with `0644`/`0660` would otherwise be signed
92    /// with while other local users could exfiltrate it. Reject before use.
93    #[error(
94        "operator key `{path}` has insecure permissions (mode {mode:#o}); expected mode `0600` (owner-only). Restore with `chmod 600 {path}` or delete and regenerate."
95    )]
96    InsecurePermissions { path: PathBuf, mode: u32 },
97    /// The on-disk operator key is not a regular file (symlink, directory,
98    /// FIFO, etc.). `O_NOFOLLOW` would have caught a symlink in `open`; this
99    /// covers everything else after `fstat`.
100    #[error(
101        "operator key `{path}` is not a regular file (symlinks, directories, FIFOs etc. are rejected)"
102    )]
103    NotRegularFile { path: PathBuf },
104    /// A directory component on the path to the operator key is a symlink.
105    /// `O_NOFOLLOW` only refuses the final component; an attacker who
106    /// controls an intermediate directory (e.g. swaps `~/.greentic` for a
107    /// symlink to their own dir before the operator's first run) would
108    /// otherwise have the load and write resolve into their controlled
109    /// directory. Caught by `lstat`-walking each parent on every load/generate.
110    #[error(
111        "operator key path `{path}`: ancestor `{ancestor}` is a symlink. Re-create the directory as a real path (e.g. `mv {ancestor} {ancestor}.symlink && mkdir -p {ancestor}`) to prevent intermediate-symlink redirection."
112    )]
113    SymlinkInAncestor { path: PathBuf, ancestor: PathBuf },
114}
115
116/// The operator's signing key + its derived id, ready for DSSE signing.
117pub struct OperatorKey {
118    /// Path the key was loaded/generated from (for diagnostics).
119    pub path: PathBuf,
120    /// PKCS#8 PEM private key. `Zeroizing` wipes the heap allocation on drop.
121    pub private_pem: Zeroizing<String>,
122    /// SPKI PEM public key.
123    pub public_pem: String,
124    /// Canonical key id (hex SHA-256 prefix of the public key, lowercase).
125    pub key_id: String,
126}
127
128impl std::fmt::Debug for OperatorKey {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("OperatorKey")
131            .field("path", &self.path)
132            .field("private_pem", &"[REDACTED]")
133            .field("public_pem_len", &self.public_pem.len())
134            .field("key_id", &self.key_id)
135            .finish()
136    }
137}
138
139/// Resolve the path the operator key should live at without touching disk.
140pub fn resolve_path() -> Result<PathBuf, OperatorKeyError> {
141    resolve_path_with(std::env::var_os(OPERATOR_KEY_PATH_ENV), dirs_home())
142}
143
144/// Decoupled resolver: explicit `override_path` (typically
145/// `std::env::var_os(OPERATOR_KEY_PATH_ENV)`) and `home`. Exposed for tests
146/// that need to exercise both branches without mutating process env.
147pub(crate) fn resolve_path_with(
148    override_path: Option<std::ffi::OsString>,
149    home: Option<PathBuf>,
150) -> Result<PathBuf, OperatorKeyError> {
151    if let Some(p) = override_path
152        && !p.is_empty()
153    {
154        return Ok(PathBuf::from(p));
155    }
156    home.map(|h| h.join(".greentic").join("operator").join("key.pem"))
157        .ok_or(OperatorKeyError::NoHome)
158}
159
160/// Load the operator key from the resolved path, generating a fresh keypair
161/// if the file does not yet exist.
162///
163/// On generation:
164/// - Parent directory is created (recursively) with default umask.
165/// - Private key written PKCS#8 PEM mode `0600` (POSIX only).
166/// - Public key written SPKI PEM as `<key>.pub` mode `0644` (POSIX only).
167///
168/// On load:
169/// - The private PEM is parsed.
170/// - If a `.pub` sibling exists, its derived id must equal the
171///   private-key-derived id; a mismatch is rejected as a stale `.pub`.
172///   If no `.pub` exists, one is written next to the private key (recovery
173///   for an operator who deleted only the public file).
174pub fn load_or_generate() -> Result<OperatorKey, OperatorKeyError> {
175    let path = resolve_path()?;
176    load_or_generate_at(&path)
177}
178
179/// Load an existing operator key, failing with `NotFound` if no key file is
180/// present. **Does not generate.** Use this from CLI verbs that should
181/// refuse to act on an unbootstrapped operator (e.g. `bundles add/update` —
182/// generating a throwaway key as a side-effect of a `bundles add` that
183/// then fails the trust-root precondition is confusing and leaves
184/// unexpected state on disk).
185pub fn load_existing_only() -> Result<OperatorKey, OperatorKeyError> {
186    let path = resolve_path()?;
187    refuse_symlink_in_ancestors(&path)?;
188    let pem = read_existing_securely(&path)?;
189    load_existing(&path, pem)
190}
191
192/// Read an existing signing key at an explicit path (e.g. `--signing-key`)
193/// with the same hardening as [`load_existing_only`]: symlink-ancestor gate,
194/// `O_NOFOLLOW` open, regular-file + mode (0600) checks, and a pre-sized
195/// `Zeroizing` read. Returns `(private_pem, key_id)`. Unlike `load_existing`,
196/// it writes NO `.pub` sibling — the path is caller-supplied, so regenerating
197/// a public file next to it would litter unexpected state. The mode/symlink
198/// enforcement is Unix-only (a no-op on other platforms, matching
199/// `read_existing_securely`).
200pub fn read_signing_key_at(path: &Path) -> Result<(Zeroizing<String>, String), OperatorKeyError> {
201    refuse_symlink_in_ancestors(path)?;
202    let private_pem = read_existing_securely(path)?;
203    let (_public_pem, key_id) = derive_public_pem_and_key_id(&private_pem)?;
204    Ok((private_pem, key_id))
205}
206
207/// Like [`load_or_generate`] but with an explicit path (tests + callers
208/// that already resolved the path themselves).
209pub fn load_or_generate_at(path: &Path) -> Result<OperatorKey, OperatorKeyError> {
210    // Reject symlinks anywhere on the path BEFORE touching the file. This
211    // closes the intermediate-symlink gap O_NOFOLLOW leaves open (only the
212    // leaf component is protected by O_NOFOLLOW). The check is best-effort
213    // against a concurrent attacker who races the create — that window is
214    // closed by the parent dir's mode in practice.
215    refuse_symlink_in_ancestors(path)?;
216    match read_existing_securely(path) {
217        Ok(private_pem) => load_existing(path, private_pem),
218        Err(OperatorKeyError::Io { source, .. })
219            if source.kind() == std::io::ErrorKind::NotFound =>
220        {
221            generate_at(path)
222        }
223        Err(other) => Err(other),
224    }
225}
226
227/// Walk every existing parent of `path` and refuse the load/generate if any
228/// is a symlink. `path` itself is allowed to not exist (the generate path
229/// creates it); only existing ancestors are checked.
230fn refuse_symlink_in_ancestors(path: &Path) -> Result<(), OperatorKeyError> {
231    let mut ancestor = path.parent();
232    while let Some(p) = ancestor {
233        if p.as_os_str().is_empty() {
234            break;
235        }
236        match std::fs::symlink_metadata(p) {
237            Ok(meta) => {
238                if meta.file_type().is_symlink() {
239                    return Err(OperatorKeyError::SymlinkInAncestor {
240                        path: path.to_path_buf(),
241                        ancestor: p.to_path_buf(),
242                    });
243                }
244            }
245            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
246                // Ancestor doesn't exist yet — create_dir_all will materialize it.
247            }
248            Err(source) => {
249                return Err(OperatorKeyError::Io {
250                    path: p.to_path_buf(),
251                    source,
252                });
253            }
254        }
255        ancestor = p.parent();
256    }
257    Ok(())
258}
259
260/// Open the operator key file refusing symlinks (`O_NOFOLLOW`), validate
261/// that it is a regular file owned by a safe principal with permissions
262/// that do not leak the key material to other local users, then read its
263/// contents into a [`Zeroizing`] buffer pre-sized to the file length so
264/// `read_to_string` does not realloc and strand earlier (unzeroed) heap
265/// allocations holding partial PEM content.
266///
267/// Returns `Io { kind = NotFound }` if the file does not exist so callers
268/// can dispatch to the generate path.
269fn read_existing_securely(path: &Path) -> Result<Zeroizing<String>, OperatorKeyError> {
270    let file = open_no_follow(path)?;
271    let meta = file.metadata().map_err(|source| OperatorKeyError::Io {
272        path: path.to_path_buf(),
273        source,
274    })?;
275    if !meta.is_file() {
276        return Err(OperatorKeyError::NotRegularFile {
277            path: path.to_path_buf(),
278        });
279    }
280    check_mode(path, &meta)?;
281    // Pre-size to file length + a small slack so `read_to_string` writes into
282    // the initial allocation rather than growing through realloc. Each
283    // realloc would free an intermediate buffer holding partial key material
284    // back to the global allocator unzeroed — see Codex review finding.
285    let len = meta.len().try_into().unwrap_or(usize::MAX);
286    let mut contents = Zeroizing::new(String::with_capacity(len.saturating_add(8)));
287    use std::io::Read;
288    {
289        let mut handle = file;
290        handle
291            .read_to_string(&mut contents)
292            .map_err(|source| OperatorKeyError::Io {
293                path: path.to_path_buf(),
294                source,
295            })?;
296    }
297    Ok(contents)
298}
299
300#[cfg(unix)]
301fn open_no_follow(path: &Path) -> Result<std::fs::File, OperatorKeyError> {
302    use std::os::unix::fs::OpenOptionsExt;
303    // O_NOFOLLOW makes `open` fail with ELOOP if the final path component is
304    // a symlink. Combined with the `is_file()` check on the resulting fd,
305    // this rejects symlinked, directory, FIFO, and device targets.
306    let result = std::fs::OpenOptions::new()
307        .read(true)
308        .custom_flags(libc::O_NOFOLLOW)
309        .open(path);
310    match result {
311        Ok(f) => Ok(f),
312        Err(e) => {
313            // ELOOP from O_NOFOLLOW maps to a "not a regular file" outcome
314            // rather than the raw io error so callers see a meaningful kind.
315            #[allow(clippy::manual_map)]
316            if let Some(raw) = e.raw_os_error()
317                && raw == libc::ELOOP
318            {
319                return Err(OperatorKeyError::NotRegularFile {
320                    path: path.to_path_buf(),
321                });
322            }
323            Err(OperatorKeyError::Io {
324                path: path.to_path_buf(),
325                source: e,
326            })
327        }
328    }
329}
330
331#[cfg(not(unix))]
332fn open_no_follow(path: &Path) -> Result<std::fs::File, OperatorKeyError> {
333    // Best-effort on non-Unix: just open the file; symlink + permissions
334    // checks are Unix-specific. `read_to_string`-style errors propagate via
335    // OperatorKeyError::Io.
336    std::fs::OpenOptions::new()
337        .read(true)
338        .open(path)
339        .map_err(|source| OperatorKeyError::Io {
340            path: path.to_path_buf(),
341            source,
342        })
343}
344
345#[cfg(unix)]
346fn check_mode(path: &Path, meta: &std::fs::Metadata) -> Result<(), OperatorKeyError> {
347    use std::os::unix::fs::MetadataExt;
348    let mode = meta.mode() & 0o777;
349    if mode & 0o077 != 0 {
350        return Err(OperatorKeyError::InsecurePermissions {
351            path: path.to_path_buf(),
352            mode,
353        });
354    }
355    Ok(())
356}
357
358#[cfg(not(unix))]
359fn check_mode(_path: &Path, _meta: &std::fs::Metadata) -> Result<(), OperatorKeyError> {
360    Ok(())
361}
362
363/// Decode a PKCS#8 private PEM, derive the SPKI public PEM and the canonical
364/// key-id (hex SHA-256 prefix). The signing key is wiped (`drop`) before
365/// returning so only the public material escapes this scope.
366fn derive_public_pem_and_key_id(private_pem: &str) -> Result<(String, String), OperatorKeyError> {
367    let sk = Ed25519SigningKey::from_pkcs8_pem(private_pem)
368        .map_err(|e| OperatorKeyError::KeyDecode(format!("PKCS#8 private PEM: {e}")))?;
369    let vk = sk.verifying_key();
370    let public_pem = vk
371        .to_public_key_pem(LineEnding::LF)
372        .map_err(|e| OperatorKeyError::KeyDecode(format!("derive SPKI PEM: {e}")))?;
373    drop(sk);
374    let key_id = key_id_for_public_key_pem(&public_pem)?;
375    Ok((public_pem, key_id))
376}
377
378fn load_existing(
379    path: &Path,
380    private_pem: Zeroizing<String>,
381) -> Result<OperatorKey, OperatorKeyError> {
382    let (public_pem, key_id) = derive_public_pem_and_key_id(&private_pem)?;
383
384    let pub_path = public_sibling(path);
385    match std::fs::read_to_string(&pub_path) {
386        Ok(existing_pub) => {
387            let existing_id = key_id_for_public_key_pem(&existing_pub).map_err(|e| {
388                OperatorKeyError::KeyDecode(format!(
389                    "`.pub` sibling at {}: {e}",
390                    pub_path.display()
391                ))
392            })?;
393            if !existing_id.eq_ignore_ascii_case(&key_id) {
394                return Err(OperatorKeyError::StalePublicKey {
395                    pub_path,
396                    pub_id: existing_id,
397                    priv_id: key_id,
398                });
399            }
400        }
401        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
402            // Operator deleted only the .pub — regenerate it from the key
403            // we just loaded so verifiers can find a public PEM on disk.
404            write_public_sibling(&pub_path, &public_pem)?;
405        }
406        Err(source) => {
407            return Err(OperatorKeyError::Io {
408                path: pub_path,
409                source,
410            });
411        }
412    }
413
414    Ok(OperatorKey {
415        path: path.to_path_buf(),
416        private_pem,
417        public_pem,
418        key_id,
419    })
420}
421
422fn generate_at(path: &Path) -> Result<OperatorKey, OperatorKeyError> {
423    // Wrap the 32-byte seed in `Zeroizing` so the raw private-key material
424    // is wiped from the stack the moment we leave this scope (or panic).
425    // Without this, the seed survives in the stack frame until the slot is
426    // overwritten by unrelated frames, and a core dump or memory-disclosure
427    // exploit can reconstruct the keypair from it.
428    let mut seed = Zeroizing::new([0u8; 32]);
429    OsRng
430        .try_fill_bytes(&mut seed[..])
431        .map_err(|e| OperatorKeyError::Entropy(e.to_string()))?;
432    let sk = Ed25519SigningKey::from_bytes(&seed);
433    let vk = sk.verifying_key();
434    // `to_pkcs8_pem` already returns a `Zeroizing<String>`; converting via
435    // `.to_string()` would clone the PEM into a bare `String` that lives
436    // unprotected until re-wrapped. Keep the underlying buffer in its
437    // original wrapper instead — no intermediate copy.
438    let private_pem: Zeroizing<String> = Zeroizing::new(
439        sk.to_pkcs8_pem(LineEnding::LF)
440            .map_err(|e| OperatorKeyError::KeyDecode(format!("encode PKCS#8 PEM: {e}")))?
441            .to_string(),
442    );
443    let public_pem = vk
444        .to_public_key_pem(LineEnding::LF)
445        .map_err(|e| OperatorKeyError::KeyDecode(format!("encode SPKI PEM: {e}")))?;
446    drop(sk);
447    let key_id = key_id_for_public_key_pem(&public_pem)?;
448
449    if let Some(parent) = path.parent()
450        && !parent.as_os_str().is_empty()
451    {
452        std::fs::create_dir_all(parent).map_err(|source| OperatorKeyError::Io {
453            path: parent.to_path_buf(),
454            source,
455        })?;
456    }
457    // Race-safe: `write_private_exclusive` uses `create_new`, so if a
458    // concurrent caller landed first we fall through to the load path
459    // and adopt their key (which has its own `.pub` already written).
460    match write_private_exclusive(path, &private_pem) {
461        Ok(()) => {
462            let pub_path = public_sibling(path);
463            // `.pub` is best-effort: if a racer just wrote one we leave it.
464            let _ = write_public_sibling_exclusive(&pub_path, &public_pem);
465            Ok(OperatorKey {
466                path: path.to_path_buf(),
467                private_pem,
468                public_pem,
469                key_id,
470            })
471        }
472        Err(OperatorKeyError::Io { source, .. })
473            if source.kind() == std::io::ErrorKind::AlreadyExists =>
474        {
475            // Racer won — adopt their key through the SAME secure read path
476            // a non-racing load would have used. The earlier plain
477            // `std::fs::read_to_string` here bypassed O_NOFOLLOW, the
478            // is_file() gate, and the 0600 mode check — an attacker who
479            // swapped the file for a symlink between the loser's
480            // persist_noclobber failure and this read would have bypassed
481            // every defense `read_existing_securely` provides.
482            let existing = read_existing_securely(path)?;
483            load_existing(path, existing)
484        }
485        Err(other) => Err(other),
486    }
487}
488
489fn public_sibling(private_path: &Path) -> PathBuf {
490    let mut s = private_path.as_os_str().to_owned();
491    s.push(".pub");
492    PathBuf::from(s)
493}
494
495/// Atomically write `contents` to `path`, refusing to overwrite. The write
496/// goes through a temp file in the same directory + `persist_noclobber`,
497/// which uses `link(2)` (POSIX) or its Windows equivalent — readers see
498/// either no file or the fully-written file, never a partial one.
499fn write_exclusive(path: &Path, contents: &str, mode: u32) -> Result<(), OperatorKeyError> {
500    use std::io::Write;
501    let parent = path
502        .parent()
503        .filter(|p| !p.as_os_str().is_empty())
504        .map(|p| p.to_path_buf())
505        .unwrap_or_else(|| std::path::Path::new(".").to_path_buf());
506    let mut tmp =
507        tempfile::NamedTempFile::new_in(&parent).map_err(|source| OperatorKeyError::Io {
508            path: parent.clone(),
509            source,
510        })?;
511    tmp.write_all(contents.as_bytes())
512        .map_err(|source| OperatorKeyError::Io {
513            path: tmp.path().to_path_buf(),
514            source,
515        })?;
516    tmp.flush().map_err(|source| OperatorKeyError::Io {
517        path: tmp.path().to_path_buf(),
518        source,
519    })?;
520    set_mode(&tmp, mode)?;
521    tmp.as_file()
522        .sync_all()
523        .map_err(|source| OperatorKeyError::Io {
524            path: tmp.path().to_path_buf(),
525            source,
526        })?;
527    tmp.persist_noclobber(path)
528        .map_err(|e| OperatorKeyError::Io {
529            path: path.to_path_buf(),
530            source: e.error,
531        })?;
532    Ok(())
533}
534
535#[cfg(unix)]
536fn set_mode(tmp: &tempfile::NamedTempFile, mode: u32) -> Result<(), OperatorKeyError> {
537    use std::os::unix::fs::PermissionsExt;
538    let perms = std::fs::Permissions::from_mode(mode);
539    std::fs::set_permissions(tmp.path(), perms).map_err(|source| OperatorKeyError::Io {
540        path: tmp.path().to_path_buf(),
541        source,
542    })
543}
544
545#[cfg(not(unix))]
546fn set_mode(_tmp: &tempfile::NamedTempFile, _mode: u32) -> Result<(), OperatorKeyError> {
547    Ok(())
548}
549
550fn write_private_exclusive(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
551    write_exclusive(path, contents, 0o600)
552}
553
554fn write_public_sibling_exclusive(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
555    write_exclusive(path, contents, 0o644)
556}
557
558/// Write the public-key sibling unconditionally (used by the load path's
559/// recovery branch when the `.pub` was deleted but the `.pem` is still
560/// present and trusted).
561fn write_public_sibling(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
562    // Best-effort exclusive write; if a racer just landed a `.pub` we
563    // leave theirs alone (load_existing already validated it matches the
564    // private-key id we share).
565    match write_public_sibling_exclusive(path, contents) {
566        Ok(()) => Ok(()),
567        Err(OperatorKeyError::Io { source, .. })
568            if source.kind() == std::io::ErrorKind::AlreadyExists =>
569        {
570            Ok(())
571        }
572        Err(other) => Err(other),
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use tempfile::tempdir;
580
581    #[cfg(unix)]
582    fn chmod(path: &Path, mode: u32) {
583        use std::os::unix::fs::PermissionsExt;
584        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
585    }
586
587    #[test]
588    fn generate_creates_keypair_with_canonical_id() {
589        let dir = tempdir().unwrap();
590        let path = dir.path().join("key.pem");
591        let key = load_or_generate_at(&path).unwrap();
592        assert_eq!(key.key_id.len(), 32);
593        assert!(key.private_pem.contains("BEGIN PRIVATE KEY"));
594        assert!(key.public_pem.contains("BEGIN PUBLIC KEY"));
595        assert!(path.is_file(), "private key file must exist");
596        let pub_path = public_sibling(&path);
597        assert!(pub_path.is_file(), "public sibling must exist");
598        let pub_id =
599            key_id_for_public_key_pem(&std::fs::read_to_string(&pub_path).unwrap()).unwrap();
600        assert_eq!(pub_id, key.key_id);
601    }
602
603    #[test]
604    fn load_is_idempotent_after_generate() {
605        let dir = tempdir().unwrap();
606        let path = dir.path().join("key.pem");
607        let first = load_or_generate_at(&path).unwrap();
608        let second = load_or_generate_at(&path).unwrap();
609        assert_eq!(first.key_id, second.key_id);
610        assert_eq!(first.public_pem, second.public_pem);
611    }
612
613    #[cfg(unix)]
614    #[test]
615    fn private_key_is_mode_0600() {
616        use std::os::unix::fs::PermissionsExt;
617        let dir = tempdir().unwrap();
618        let path = dir.path().join("key.pem");
619        load_or_generate_at(&path).unwrap();
620        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
621        assert_eq!(mode, 0o600);
622    }
623
624    #[test]
625    fn stale_pub_sibling_is_rejected() {
626        let dir = tempdir().unwrap();
627        let path = dir.path().join("key.pem");
628        load_or_generate_at(&path).unwrap();
629        // Overwrite .pub with a different valid SPKI PEM.
630        let other_sk = Ed25519SigningKey::from_bytes(&[7u8; 32]);
631        let other_pub = other_sk
632            .verifying_key()
633            .to_public_key_pem(LineEnding::LF)
634            .unwrap();
635        let pub_path = public_sibling(&path);
636        std::fs::write(&pub_path, &other_pub).unwrap();
637
638        let err = load_or_generate_at(&path).expect_err("stale pub must be rejected");
639        assert!(matches!(err, OperatorKeyError::StalePublicKey { .. }));
640    }
641
642    #[test]
643    fn missing_pub_sibling_is_regenerated_on_load() {
644        let dir = tempdir().unwrap();
645        let path = dir.path().join("key.pem");
646        load_or_generate_at(&path).unwrap();
647        let pub_path = public_sibling(&path);
648        std::fs::remove_file(&pub_path).unwrap();
649
650        let key = load_or_generate_at(&path).unwrap();
651        assert!(pub_path.is_file(), "pub sibling must be regenerated");
652        let pub_id =
653            key_id_for_public_key_pem(&std::fs::read_to_string(&pub_path).unwrap()).unwrap();
654        assert_eq!(pub_id, key.key_id);
655    }
656
657    #[test]
658    fn env_override_takes_precedence() {
659        let dir = tempdir().unwrap();
660        let path = dir.path().join("override.pem");
661        let resolved = resolve_path_with(
662            Some(path.as_os_str().to_owned()),
663            Some(PathBuf::from("/should-not-be-used")),
664        )
665        .unwrap();
666        assert_eq!(resolved, path);
667    }
668
669    #[test]
670    fn empty_env_override_falls_through_to_home() {
671        let home = PathBuf::from("/home/op");
672        let resolved =
673            resolve_path_with(Some(std::ffi::OsString::new()), Some(home.clone())).unwrap();
674        assert_eq!(
675            resolved,
676            home.join(".greentic").join("operator").join("key.pem")
677        );
678    }
679
680    #[test]
681    fn missing_env_and_missing_home_is_no_home_error() {
682        let err = resolve_path_with(None, None).expect_err("must error");
683        assert!(matches!(err, OperatorKeyError::NoHome));
684    }
685
686    #[test]
687    fn debug_redacts_private_pem() {
688        let dir = tempdir().unwrap();
689        let path = dir.path().join("key.pem");
690        let key = load_or_generate_at(&path).unwrap();
691        let dbg = format!("{key:?}");
692        assert!(dbg.contains("[REDACTED]"));
693        assert!(!dbg.contains("BEGIN PRIVATE KEY"));
694    }
695
696    #[test]
697    fn concurrent_generates_converge_on_one_key() {
698        // The cold-start race that motivated the atomic-write fix: N threads
699        // all hit a non-existent path; exactly one wins the exclusive create
700        // and the rest must adopt the winner's key, never a partial PEM.
701        let dir = tempdir().unwrap();
702        let path = std::sync::Arc::new(dir.path().join("key.pem"));
703        let handles: Vec<_> = (0..8)
704            .map(|_| {
705                let p = path.clone();
706                std::thread::spawn(move || load_or_generate_at(&p).unwrap().key_id)
707            })
708            .collect();
709        let ids: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
710        let first = &ids[0];
711        for id in &ids {
712            assert_eq!(id, first, "all racers must adopt one canonical key");
713        }
714    }
715
716    #[cfg(unix)]
717    #[test]
718    fn existing_key_with_world_readable_mode_is_rejected() {
719        let dir = tempdir().unwrap();
720        let path = dir.path().join("key.pem");
721        load_or_generate_at(&path).unwrap();
722        chmod(&path, 0o644);
723        let err = load_or_generate_at(&path).expect_err("0644 must be rejected");
724        match err {
725            OperatorKeyError::InsecurePermissions { mode, .. } => assert_eq!(mode, 0o644),
726            other => panic!("expected InsecurePermissions, got {other:?}"),
727        }
728    }
729
730    #[cfg(unix)]
731    #[test]
732    fn existing_key_with_group_readable_mode_is_rejected() {
733        let dir = tempdir().unwrap();
734        let path = dir.path().join("key.pem");
735        load_or_generate_at(&path).unwrap();
736        chmod(&path, 0o660);
737        let err = load_or_generate_at(&path).expect_err("0660 must be rejected");
738        match err {
739            OperatorKeyError::InsecurePermissions { mode, .. } => assert_eq!(mode, 0o660),
740            other => panic!("expected InsecurePermissions, got {other:?}"),
741        }
742    }
743
744    #[cfg(unix)]
745    #[test]
746    fn existing_key_with_mode_0600_is_accepted() {
747        let dir = tempdir().unwrap();
748        let path = dir.path().join("key.pem");
749        let first = load_or_generate_at(&path).unwrap();
750        chmod(&path, 0o600);
751        let second = load_or_generate_at(&path).unwrap();
752        assert_eq!(first.key_id, second.key_id);
753    }
754
755    #[cfg(unix)]
756    #[test]
757    fn symlinked_key_path_is_rejected() {
758        let dir = tempdir().unwrap();
759        let real_path = dir.path().join("real-key.pem");
760        load_or_generate_at(&real_path).unwrap();
761        let link_path = dir.path().join("via-link.pem");
762        std::os::unix::fs::symlink(&real_path, &link_path).unwrap();
763        let err = load_or_generate_at(&link_path).expect_err("symlink target must be rejected");
764        assert!(
765            matches!(err, OperatorKeyError::NotRegularFile { .. }),
766            "expected NotRegularFile, got {err:?}"
767        );
768    }
769
770    #[cfg(unix)]
771    #[test]
772    fn symlinked_intermediate_directory_is_rejected() {
773        // Codex/xhigh #5: O_NOFOLLOW protects only the leaf, so an attacker
774        // who swaps `~/.greentic` for a symlink to their own dir would
775        // otherwise redirect the load. `refuse_symlink_in_ancestors` walks
776        // the parent chain and refuses any symlinked component.
777        let dir = tempdir().unwrap();
778        let attacker_root = dir.path().join("evil");
779        std::fs::create_dir_all(&attacker_root).unwrap();
780        let symlinked_parent = dir.path().join("operator");
781        std::os::unix::fs::symlink(&attacker_root, &symlinked_parent).unwrap();
782        let key_path = symlinked_parent.join("key.pem");
783        let err =
784            load_or_generate_at(&key_path).expect_err("intermediate symlink must be rejected");
785        match err {
786            OperatorKeyError::SymlinkInAncestor { ancestor, .. } => {
787                assert_eq!(ancestor, symlinked_parent);
788            }
789            other => panic!("expected SymlinkInAncestor, got {other:?}"),
790        }
791        // No key file was created inside the attacker's directory.
792        assert!(
793            !attacker_root.join("key.pem").exists(),
794            "load must refuse before any write reaches the symlinked dir"
795        );
796    }
797
798    #[cfg(unix)]
799    #[test]
800    fn directory_at_key_path_is_rejected() {
801        let dir = tempdir().unwrap();
802        let path = dir.path().join("key.pem");
803        std::fs::create_dir(&path).unwrap();
804        let err = load_or_generate_at(&path).expect_err("directory must be rejected");
805        // O_NOFOLLOW on a directory opens fine; the is_file check catches it.
806        assert!(
807            matches!(err, OperatorKeyError::NotRegularFile { .. }),
808            "expected NotRegularFile, got {err:?}"
809        );
810    }
811
812    #[test]
813    fn corrupted_private_pem_is_rejected() {
814        let dir = tempdir().unwrap();
815        let path = dir.path().join("key.pem");
816        std::fs::write(
817            &path,
818            "-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----\n",
819        )
820        .unwrap();
821        // Set safe mode so the permission gate doesn't fire first — the test
822        // is about PEM corruption, not file permissions.
823        #[cfg(unix)]
824        chmod(&path, 0o600);
825        let err = load_or_generate_at(&path).expect_err("bad PEM must reject");
826        assert!(matches!(err, OperatorKeyError::KeyDecode(_)));
827    }
828
829    // ── read_signing_key_at tests ──────────────────────────────────────
830
831    #[test]
832    fn read_signing_key_at_valid_key_matches_load_or_generate() {
833        let dir = tempdir().unwrap();
834        let path = dir.path().join("key.pem");
835        let generated = load_or_generate_at(&path).unwrap();
836        let (pem, kid) = read_signing_key_at(&path).unwrap();
837        assert_eq!(kid.len(), 32, "key_id must be a 32-hex-char string");
838        assert_eq!(
839            kid, generated.key_id,
840            "key_id must match load_or_generate_at"
841        );
842        assert_eq!(*pem, *generated.private_pem);
843    }
844
845    #[cfg(unix)]
846    #[test]
847    fn read_signing_key_at_rejects_mode_0644() {
848        let dir = tempdir().unwrap();
849        let path = dir.path().join("key.pem");
850        load_or_generate_at(&path).unwrap();
851        chmod(&path, 0o644);
852        let err = read_signing_key_at(&path).expect_err("0644 must be rejected");
853        assert!(
854            matches!(err, OperatorKeyError::InsecurePermissions { .. }),
855            "expected InsecurePermissions, got {err:?}"
856        );
857    }
858
859    #[cfg(unix)]
860    #[test]
861    fn read_signing_key_at_rejects_symlinked_key() {
862        let dir = tempdir().unwrap();
863        let real = dir.path().join("real.pem");
864        load_or_generate_at(&real).unwrap();
865        let link = dir.path().join("link.pem");
866        std::os::unix::fs::symlink(&real, &link).unwrap();
867        let err = read_signing_key_at(&link).expect_err("symlink must be rejected");
868        assert!(
869            matches!(err, OperatorKeyError::NotRegularFile { .. }),
870            "expected NotRegularFile, got {err:?}"
871        );
872    }
873
874    #[cfg(unix)]
875    #[test]
876    fn read_signing_key_at_rejects_symlinked_ancestor() {
877        let dir = tempdir().unwrap();
878        let real_dir = dir.path().join("real-dir");
879        std::fs::create_dir_all(&real_dir).unwrap();
880        let key_in_real = real_dir.join("key.pem");
881        load_or_generate_at(&key_in_real).unwrap();
882        let sym_dir = dir.path().join("sym-dir");
883        std::os::unix::fs::symlink(&real_dir, &sym_dir).unwrap();
884        let key_via_sym = sym_dir.join("key.pem");
885        let err = read_signing_key_at(&key_via_sym).expect_err("ancestor symlink must reject");
886        match err {
887            OperatorKeyError::SymlinkInAncestor { ancestor, .. } => {
888                assert_eq!(ancestor, sym_dir);
889            }
890            other => panic!("expected SymlinkInAncestor, got {other:?}"),
891        }
892    }
893
894    #[test]
895    fn read_signing_key_at_does_not_write_pub_sibling() {
896        let dir = tempdir().unwrap();
897        let path = dir.path().join("key.pem");
898        load_or_generate_at(&path).unwrap();
899        let pub_path = public_sibling(&path);
900        assert!(pub_path.exists());
901        std::fs::remove_file(&pub_path).unwrap();
902        let (_pem, _kid) = read_signing_key_at(&path).unwrap();
903        assert!(
904            !pub_path.exists(),
905            "read_signing_key_at must not create a .pub sibling"
906        );
907    }
908
909    #[test]
910    fn read_signing_key_at_nonexistent_yields_not_found() {
911        let dir = tempdir().unwrap();
912        let path = dir.path().join("does-not-exist.pem");
913        let err = read_signing_key_at(&path).expect_err("missing file must error");
914        match err {
915            OperatorKeyError::Io { source, .. } => {
916                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
917            }
918            other => panic!("expected Io with NotFound, got {other:?}"),
919        }
920    }
921}