Skip to main content

hardware_enclave/internal/core/
metadata.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! Key metadata and file operations for hardware-backed key management.
5#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
6
7use super::error::{Error, Result};
8use fs2::FileExt;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeSet;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14pub fn meta_warning_default() -> String {
15    "HMAC-verified — do not modify this file directly. Use CLI tools (e.g. sshenc identity)."
16        .to_string()
17}
18
19/// Metadata stored alongside a hardware-bound key.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct KeyMeta {
22    /// Tamper warning rendered at the top of the JSON.
23    #[serde(default = "meta_warning_default", rename = "_warning")]
24    pub warning: String,
25    /// Key label (unique identifier within the app).
26    pub label: String,
27    /// Type of key (signing or encryption). Defaults to Signing for backward
28    /// compatibility with metadata files created before this field existed.
29    #[serde(default)]
30    pub key_type: crate::internal::core::KeyType,
31    /// Access control policy.
32    #[serde(default)]
33    pub access_policy: crate::internal::core::AccessPolicy,
34    /// Unix timestamp when the key was created.
35    #[serde(default)]
36    pub created: String,
37    /// Application-specific extra fields (e.g., git_name, git_email for sshenc;
38    /// profile name for awsenc; server/env for sso-jwt).
39    #[serde(default)]
40    pub app_specific: serde_json::Value,
41}
42
43impl KeyMeta {
44    /// Create a new KeyMeta with the current timestamp.
45    pub fn new(
46        label: &str,
47        key_type: crate::internal::core::KeyType,
48        access_policy: crate::internal::core::AccessPolicy,
49    ) -> Self {
50        let created = std::time::SystemTime::now()
51            .duration_since(std::time::UNIX_EPOCH)
52            .unwrap_or_default()
53            .as_secs()
54            .to_string();
55        KeyMeta {
56            warning: meta_warning_default(),
57            label: label.to_string(),
58            key_type,
59            access_policy,
60            created,
61            app_specific: serde_json::Value::Null,
62        }
63    }
64
65    /// Set an app-specific field.
66    pub fn set_app_field(&mut self, key: &str, value: impl Into<serde_json::Value>) {
67        if self.app_specific.is_null() {
68            self.app_specific = serde_json::Value::Object(serde_json::Map::new());
69        }
70        if let Some(obj) = self.app_specific.as_object_mut() {
71            obj.insert(key.to_string(), value.into());
72        }
73    }
74
75    /// Get an app-specific string field.
76    pub fn get_app_field(&self, key: &str) -> Option<&str> {
77        self.app_specific.get(key)?.as_str()
78    }
79}
80
81/// Standard keys directory for an application.
82/// - Unix: `~/.config/<app_name>/keys/`
83/// - Windows: `%APPDATA%/<app_name>/keys/`
84pub fn keys_dir(app_name: &str) -> PathBuf {
85    config_dir(app_name).join("keys")
86}
87
88/// Standard config directory for an application.
89/// - Unix: `~/.config/<app_name>/`
90/// - Windows: `%APPDATA%/<app_name>/`
91pub fn config_dir(app_name: &str) -> PathBuf {
92    dirs::config_dir()
93        .unwrap_or_else(|| {
94            dirs::home_dir()
95                .unwrap_or_else(|| PathBuf::from("/tmp"))
96                .join(".config")
97        })
98        .join(app_name)
99}
100
101/// Write data atomically: write to a temp file, then rename into place.
102pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
103    atomic_write_with_sync(path, data, sync_parent_dir)
104}
105
106/// Read a file, refusing to follow symlinks at the target path.
107///
108/// On Unix uses `open(..., O_NOFOLLOW)` which returns `ELOOP` if `path`
109/// is a symlink, closing the TOCTOU window that a pre-stat / post-read
110/// symlink swap would open.  On Windows symlinks in the keys directory
111/// are uncommon; we use a `symlink_metadata()` pre-check that is racy
112/// relative to a simultaneous attacker rename, but good enough given
113/// the threat model (same-UID attacker with user-profile write access).
114///
115/// Intended for loading key material (handle blobs, pub keys, `.meta`
116/// files) whose paths are constructed from user-controlled labels.
117pub fn read_no_follow(path: &Path) -> Result<Vec<u8>> {
118    #[cfg(unix)]
119    {
120        use std::io::Read;
121        use std::os::unix::fs::OpenOptionsExt;
122        let mut file = std::fs::OpenOptions::new()
123            .read(true)
124            .custom_flags(libc::O_NOFOLLOW)
125            .open(path)?;
126        let mut buf = Vec::new();
127        file.read_to_end(&mut buf)?;
128        Ok(buf)
129    }
130    #[cfg(not(unix))]
131    {
132        let meta = std::fs::symlink_metadata(path)?;
133        if meta.file_type().is_symlink() {
134            return Err(Error::Io(std::io::Error::new(
135                std::io::ErrorKind::InvalidInput,
136                format!("refusing to read symlink at {}", path.display()),
137            )));
138        }
139        std::fs::read(path).map_err(Error::Io)
140    }
141}
142
143/// Read-to-string variant of [`read_no_follow`].
144pub fn read_to_string_no_follow(path: &Path) -> Result<String> {
145    let bytes = read_no_follow(path)?;
146    String::from_utf8(bytes).map_err(|e| {
147        Error::Io(std::io::Error::new(
148            std::io::ErrorKind::InvalidData,
149            format!("{} is not valid UTF-8: {e}", path.display()),
150        ))
151    })
152}
153
154fn atomic_write_with_sync<F>(path: &Path, data: &[u8], sync_parent: F) -> Result<()>
155where
156    F: Fn(&Path) -> Result<()>,
157{
158    let parent = path.parent().ok_or_else(|| {
159        Error::Io(std::io::Error::new(
160            std::io::ErrorKind::InvalidInput,
161            "atomic_write path has no parent directory",
162        ))
163    })?;
164    let tmp = unique_temp_path(parent, path);
165    let mut file = std::fs::OpenOptions::new()
166        .create_new(true)
167        .write(true)
168        .open(&tmp)?;
169    file.write_all(data)?;
170    file.sync_all()?;
171    drop(file);
172    if let Err(e) = std::fs::rename(&tmp, path) {
173        std::fs::remove_file(&tmp).ok();
174        return Err(e.into());
175    }
176    sync_parent(parent)?;
177    Ok(())
178}
179
180#[cfg(unix)]
181fn sync_parent_dir(path: &Path) -> Result<()> {
182    let dir = std::fs::File::open(path)?;
183    dir.sync_all()?;
184    Ok(())
185}
186
187#[cfg(not(unix))]
188fn sync_parent_dir(_path: &Path) -> Result<()> {
189    Ok(())
190}
191
192fn unique_temp_path(parent: &Path, path: &Path) -> PathBuf {
193    let file_name = path
194        .file_name()
195        .and_then(|name| name.to_str())
196        .unwrap_or("tmp");
197    let pid = std::process::id();
198    let nanos = std::time::SystemTime::now()
199        .duration_since(std::time::UNIX_EPOCH)
200        .unwrap_or_default()
201        .as_nanos();
202    parent.join(format!(".{file_name}.{pid}.{nanos}.tmp"))
203}
204
205/// File-based directory lock using flock (Unix) or LockFile (Windows).
206/// Prevents concurrent writes to the keys directory.
207#[derive(Debug)]
208pub struct DirLock {
209    _file: std::fs::File,
210}
211
212impl DirLock {
213    /// Acquire an exclusive lock on the given directory.
214    pub fn acquire(dir: &Path) -> Result<Self> {
215        let lock_path = dir.join(".lock");
216        let file = std::fs::OpenOptions::new()
217            .create(true)
218            .read(true)
219            .write(true)
220            .truncate(false)
221            .open(&lock_path)?;
222        file.lock_exclusive().map_err(Error::Io)?;
223        Ok(DirLock { _file: file })
224    }
225}
226
227/// Ensure a directory exists with restrictive permissions (0700 on Unix).
228pub fn ensure_dir(dir: &Path) -> Result<()> {
229    std::fs::create_dir_all(dir)?;
230    #[cfg(unix)]
231    {
232        use std::os::unix::fs::PermissionsExt;
233        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
234    }
235    Ok(())
236}
237
238/// Set restrictive file permissions (0600 on Unix).
239#[cfg_attr(not(unix), allow(unused_variables))]
240pub fn restrict_file_permissions(path: &Path) -> Result<()> {
241    #[cfg(unix)]
242    {
243        use std::os::unix::fs::PermissionsExt;
244        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
245    }
246    Ok(())
247}
248
249/// Save key metadata to a JSON file.
250///
251/// # Meta-tag invariant
252///
253/// On platforms with meta-integrity tags (macOS, Windows, Linux),
254/// every call to `save_meta` MUST be followed by a meta-tag re-stamp.
255/// Callers that skip the re-stamp will break `ensure_meta_integrity`
256/// verification on the next load. The canonical way to guarantee this
257/// is to route meta mutations through the agent process, which stamps
258/// the tag atomically. See sshenc-agent's `SetIdentity` handler.
259pub fn save_meta(dir: &Path, label: &str, meta: &KeyMeta) -> Result<()> {
260    crate::internal::core::types::validate_label(label)?;
261    let meta_path = dir.join(format!("{label}.meta"));
262    let json =
263        serde_json::to_string_pretty(meta).map_err(|e| Error::Serialization(e.to_string()))?;
264    atomic_write(&meta_path, json.as_bytes())
265}
266
267/// Save key metadata plus an HMAC sidecar (`<label>.meta.hmac`) that
268/// authenticates the meta JSON under `hmac_key`.
269///
270/// Intended for backends whose meta tamper is a full policy bypass —
271/// i.e. the software/keyring backend, where the hardware does not
272/// re-enforce `AccessPolicy` at sign/decrypt time. Callers that hold
273/// a per-app HMAC key (stored in the system keyring alongside the
274/// KEK) invoke this instead of [`save_meta`]. The hardware backends
275/// continue to call the plain [`save_meta`] because their key
276/// enforcement is fixed at key-creation time on the chip and cannot
277/// be relaxed by editing `.meta`.
278pub fn save_meta_with_hmac(dir: &Path, label: &str, meta: &KeyMeta, hmac_key: &[u8]) -> Result<()> {
279    crate::internal::core::types::validate_label(label)?;
280    let meta_path = dir.join(format!("{label}.meta"));
281    let json =
282        serde_json::to_string_pretty(meta).map_err(|e| Error::Serialization(e.to_string()))?;
283    atomic_write(&meta_path, json.as_bytes())?;
284
285    let tag = compute_meta_hmac(hmac_key, json.as_bytes());
286    let hmac_path = dir.join(format!("{label}.meta.hmac"));
287    atomic_write(&hmac_path, tag.as_bytes())?;
288    Ok(())
289}
290
291/// Integrity policy for [`load_meta_with_hmac`].
292///
293/// On the keyring/software backend, `<label>.meta.hmac` is the only
294/// thing that authenticates the `.meta` JSON — meta-tamper without
295/// the sidecar means a same-UID attacker can lie about
296/// `AccessPolicy` (and any other policy-bearing field) without
297/// keyring access. Strict mode refuses missing sidecars so the
298/// promise in the threat model ("attacker without keyring access is
299/// caught") actually holds. Legacy mode is for one-shot migration
300/// from caches that pre-date the sidecar.
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum MetaIntegrityMode {
303    /// Both `.meta` and `.meta.hmac` must be present and verify. A
304    /// missing sidecar is a hard error
305    /// (`Error::KeyOperation { operation: "meta_hmac_missing", … }`).
306    /// New production code should use this mode.
307    RequireSidecar,
308    /// A missing sidecar is treated as a legacy cache from before the
309    /// HMAC sidecar shipped: the meta JSON is loaded verbatim and the
310    /// caller is expected to migrate via [`migrate_meta_to_hmac`]
311    /// immediately after a successful load. Reserved for the
312    /// one-time upgrade path.
313    AllowLegacyMissingSidecar,
314}
315
316/// Operation tag used in `Error::KeyOperation` when strict-mode
317/// HMAC loading sees `.meta` without a `.meta.hmac` sidecar.
318pub const META_HMAC_MISSING_OP: &str = "meta_hmac_missing";
319
320/// Operation tag used when a `.meta.hmac` sidecar exists but does
321/// not match the recomputed HMAC of the `.meta` JSON.
322pub const META_HMAC_VERIFY_OP: &str = "meta_hmac_verify";
323
324/// Load key metadata from a JSON file. Returns a default if the file doesn't exist.
325pub fn load_meta(dir: &Path, label: &str) -> Result<KeyMeta> {
326    crate::internal::core::types::validate_label(label)?;
327    let meta_path = dir.join(format!("{label}.meta"));
328    if !meta_path.exists() {
329        return Ok(KeyMeta {
330            warning: meta_warning_default(),
331            label: label.to_string(),
332            key_type: crate::internal::core::KeyType::Signing,
333            access_policy: crate::internal::core::AccessPolicy::None,
334            created: String::new(),
335            app_specific: serde_json::Value::Null,
336        });
337    }
338    let content = read_to_string_no_follow(&meta_path)?;
339    serde_json::from_str(&content).map_err(|e| Error::Serialization(e.to_string()))
340}
341
342/// Load key metadata with an HMAC check.
343///
344/// Behavior depends on `mode`:
345///
346/// - [`MetaIntegrityMode::RequireSidecar`]: both `<label>.meta` and
347///   `<label>.meta.hmac` must be present and verify. A missing
348///   `.meta.hmac` returns [`Error::KeyOperation`] with
349///   `operation = "meta_hmac_missing"`; a mismatching one returns
350///   `operation = "meta_hmac_verify"`.
351/// - [`MetaIntegrityMode::AllowLegacyMissingSidecar`]: a missing
352///   `.meta.hmac` is treated as a legacy cache — the meta JSON is
353///   returned verbatim and the caller is expected to migrate via
354///   [`migrate_meta_to_hmac`].
355///
356/// If `<label>.meta` itself is missing the function returns the
357/// default empty `KeyMeta` via [`load_meta`] in either mode.
358pub fn load_meta_with_hmac(
359    dir: &Path,
360    label: &str,
361    hmac_key: &[u8],
362    mode: MetaIntegrityMode,
363) -> Result<KeyMeta> {
364    crate::internal::core::types::validate_label(label)?;
365    let meta_path = dir.join(format!("{label}.meta"));
366    if !meta_path.exists() {
367        return load_meta(dir, label);
368    }
369    let content = read_to_string_no_follow(&meta_path)?;
370
371    let hmac_path = dir.join(format!("{label}.meta.hmac"));
372    if hmac_path.exists() {
373        let expected_hex = read_to_string_no_follow(&hmac_path)?;
374        let actual_hex = compute_meta_hmac(hmac_key, content.as_bytes());
375        if !constant_time_eq(expected_hex.trim().as_bytes(), actual_hex.as_bytes()) {
376            return Err(Error::KeyOperation {
377                operation: META_HMAC_VERIFY_OP.into(),
378                detail: format!(
379                    "`.meta.hmac` does not match the stored `.meta` JSON for label {label}: \
380                     metadata was tampered with after save"
381                ),
382            });
383        }
384    } else if mode == MetaIntegrityMode::RequireSidecar {
385        return Err(Error::KeyOperation {
386            operation: META_HMAC_MISSING_OP.into(),
387            detail: format!(
388                "`.meta` is present without a `.meta.hmac` sidecar for label {label}: \
389                 either the sidecar was deleted (tamper) or this is a legacy meta \
390                 that needs `migrate_meta_to_hmac`"
391            ),
392        });
393    }
394
395    serde_json::from_str(&content).map_err(|e| Error::Serialization(e.to_string()))
396}
397
398/// Write a `<label>.meta.hmac` sidecar for an existing `<label>.meta`
399/// using `hmac_key`. Used by callers that hit a missing-sidecar load
400/// in legacy mode and want to upgrade the on-disk artifacts so
401/// subsequent strict loads succeed.
402///
403/// This blesses the current `.meta` content as authentic. Callers
404/// that need to detect tamper before migration must do so via some
405/// other channel (e.g. a separately-authenticated marker in the
406/// keyring). Returns the sidecar path on success.
407pub fn migrate_meta_to_hmac(dir: &Path, label: &str, hmac_key: &[u8]) -> Result<PathBuf> {
408    crate::internal::core::types::validate_label(label)?;
409    let meta_path = dir.join(format!("{label}.meta"));
410    if !meta_path.exists() {
411        return Err(Error::KeyNotFound {
412            label: label.to_string(),
413        });
414    }
415    let content = read_to_string_no_follow(&meta_path)?;
416    let tag = compute_meta_hmac(hmac_key, content.as_bytes());
417    let hmac_path = dir.join(format!("{label}.meta.hmac"));
418    atomic_write(&hmac_path, tag.as_bytes())?;
419    Ok(hmac_path)
420}
421
422/// Compute HMAC-SHA256 over `data` keyed by `key`, hex-encoded.
423///
424/// Implemented directly over SHA-256 per RFC 2104 so we don't pull in
425/// a new dep for a single use. The output is lowercase hex, 64 chars.
426fn compute_meta_hmac(key: &[u8], data: &[u8]) -> String {
427    let bytes = compute_meta_hmac_bytes(key, data);
428    let mut out = String::with_capacity(64);
429    for byte in bytes {
430        out.push_str(&format!("{byte:02x}"));
431    }
432    out
433}
434
435/// Compute HMAC-SHA256 over `data` keyed by `key`, returned as raw
436/// bytes.
437///
438/// Same algorithm as [`compute_meta_hmac`]; this variant skips the
439/// hex encoding for callers that need the raw tag (e.g., the macOS
440/// per-key meta-tag store, which persists 32 bytes directly into a
441/// keychain item).
442pub fn compute_meta_hmac_bytes(key: &[u8], data: &[u8]) -> [u8; 32] {
443    use sha2::{Digest, Sha256};
444
445    const BLOCK_SIZE: usize = 64; // SHA-256 block size
446
447    // Prepare K' — either pad to block size, or hash first if key > block.
448    let mut k = [0_u8; BLOCK_SIZE];
449    if key.len() > BLOCK_SIZE {
450        let hashed = Sha256::digest(key);
451        k[..hashed.len()].copy_from_slice(&hashed);
452    } else {
453        k[..key.len()].copy_from_slice(key);
454    }
455
456    let mut ipad = [0x36_u8; BLOCK_SIZE];
457    let mut opad = [0x5c_u8; BLOCK_SIZE];
458    for i in 0..BLOCK_SIZE {
459        ipad[i] ^= k[i];
460        opad[i] ^= k[i];
461    }
462
463    let mut inner = Sha256::new();
464    inner.update(ipad);
465    inner.update(data);
466    let inner_digest = inner.finalize();
467
468    let mut outer = Sha256::new();
469    outer.update(opad);
470    outer.update(inner_digest);
471    let outer_digest = outer.finalize();
472
473    let mut out = [0_u8; 32];
474    out.copy_from_slice(&outer_digest);
475    out
476}
477
478/// Constant-time equality. Returns `true` iff `a == b`.
479fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
480    if a.len() != b.len() {
481        return false;
482    }
483    let mut diff: u8 = 0;
484    for (x, y) in a.iter().zip(b.iter()) {
485        diff |= x ^ y;
486    }
487    diff == 0
488}
489
490/// Save a cached public key file.
491pub fn save_pub_key(dir: &Path, label: &str, pub_key: &[u8]) -> Result<()> {
492    crate::internal::core::types::validate_label(label)?;
493    let path = dir.join(format!("{label}.pub"));
494    atomic_write(&path, pub_key)
495}
496
497/// Load a cached public key file.
498pub fn load_pub_key(dir: &Path, label: &str) -> Result<Vec<u8>> {
499    crate::internal::core::types::validate_label(label)?;
500    let path = dir.join(format!("{label}.pub"));
501    if !path.exists() {
502        return Err(Error::KeyNotFound {
503            label: label.to_string(),
504        });
505    }
506    read_no_follow(&path)
507}
508
509/// Refresh the cached public key from authoritative source bytes.
510pub fn sync_pub_key(dir: &Path, label: &str, pub_key: &[u8]) -> Result<Vec<u8>> {
511    crate::internal::core::types::validate_label(label)?;
512    crate::internal::core::types::validate_p256_point(pub_key)?;
513
514    match load_pub_key(dir, label) {
515        Ok(existing) if existing == pub_key => Ok(existing),
516        _ => {
517            save_pub_key(dir, label, pub_key)?;
518            Ok(pub_key.to_vec())
519        }
520    }
521}
522
523/// List all key labels by scanning for `.meta` files in the directory.
524pub fn list_labels(dir: &Path) -> Result<Vec<String>> {
525    list_labels_for_extensions(dir, &["meta"])
526}
527
528/// List key labels by scanning for any of the provided file extensions.
529pub fn list_labels_for_extensions(dir: &Path, extensions: &[&str]) -> Result<Vec<String>> {
530    if !dir.exists() {
531        return Ok(Vec::new());
532    }
533    let mut labels = BTreeSet::new();
534    for entry in std::fs::read_dir(dir)? {
535        let entry = entry?;
536        let path = entry.path();
537        if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
538            if !extensions.contains(&extension) {
539                continue;
540            }
541            if let Some(stem) = path.file_stem() {
542                let label = stem.to_string_lossy().to_string();
543                if crate::internal::core::types::validate_label(&label).is_ok() {
544                    labels.insert(label);
545                }
546            }
547        }
548    }
549    Ok(labels.into_iter().collect())
550}
551
552/// Delete all files associated with a key label.
553pub fn delete_key_files(dir: &Path, label: &str) -> Result<()> {
554    crate::internal::core::types::validate_label(label)?;
555    let extensions = ["meta", "meta.hmac", "pub", "handle", "ssh.pub"];
556    let mut found_any = false;
557    for ext in &extensions {
558        let path = dir.join(format!("{label}.{ext}"));
559        if path.exists() {
560            std::fs::remove_file(&path)?;
561            found_any = true;
562        }
563    }
564    if !found_any {
565        return Err(Error::KeyNotFound {
566            label: label.to_string(),
567        });
568    }
569    Ok(())
570}
571
572/// Returns true if any metadata/public/handle files exist for the given label.
573pub fn key_files_exist(dir: &Path, label: &str) -> Result<bool> {
574    crate::internal::core::types::validate_label(label)?;
575    Ok(["meta", "pub", "handle", "ssh.pub"]
576        .into_iter()
577        .any(|ext| dir.join(format!("{label}.{ext}")).exists()))
578}
579
580/// Rename all files associated with a key label.
581///
582/// If a `<old_label>.meta.hmac` sidecar exists, the caller must
583/// supply `hmac_key` so the sidecar can be recomputed against the
584/// renamed-and-relabeled meta JSON. Passing `None` when a sidecar is
585/// present returns an error rather than leaving an orphan or stale
586/// sidecar — the latter would break subsequent strict
587/// [`load_meta_with_hmac`] calls.
588pub fn rename_key_files(
589    dir: &Path,
590    old_label: &str,
591    new_label: &str,
592    hmac_key: Option<&[u8]>,
593) -> Result<()> {
594    rename_key_files_with_writer(dir, old_label, new_label, hmac_key, atomic_write)
595}
596
597fn rename_key_files_with_writer<F>(
598    dir: &Path,
599    old_label: &str,
600    new_label: &str,
601    hmac_key: Option<&[u8]>,
602    metadata_writer: F,
603) -> Result<()>
604where
605    F: Fn(&Path, &[u8]) -> Result<()>,
606{
607    crate::internal::core::types::validate_label(old_label)?;
608    crate::internal::core::types::validate_label(new_label)?;
609    let old_handle = dir.join(format!("{old_label}.handle"));
610    let old_meta = dir.join(format!("{old_label}.meta"));
611    if !old_handle.exists() && !old_meta.exists() {
612        return Err(Error::KeyNotFound {
613            label: old_label.to_string(),
614        });
615    }
616    if key_files_exist(dir, new_label)? {
617        return Err(Error::DuplicateLabel {
618            label: new_label.to_string(),
619        });
620    }
621    let old_hmac = dir.join(format!("{old_label}.meta.hmac"));
622    if old_hmac.exists() && hmac_key.is_none() {
623        return Err(Error::KeyOperation {
624            operation: "rename_key_files".into(),
625            detail: format!(
626                "`{old_label}.meta.hmac` sidecar exists but no hmac_key was supplied; \
627                 rename would leave the sidecar stale or orphaned"
628            ),
629        });
630    }
631    // The handle/pub/ssh.pub files just move; .meta moves too but
632    // its content is rewritten below; .meta.hmac is regenerated
633    // below from the rewritten meta and is not part of the rename.
634    let extensions = ["meta", "pub", "handle", "ssh.pub"];
635    let mut renamed = Vec::new();
636    for ext in &extensions {
637        let old = dir.join(format!("{old_label}.{ext}"));
638        let new = dir.join(format!("{new_label}.{ext}"));
639        if old.exists() {
640            if let Err(err) = std::fs::rename(&old, &new) {
641                rollback_renames(&renamed)?;
642                return Err(err.into());
643            }
644            renamed.push((old, new));
645        }
646    }
647    // Update the label in the metadata file
648    let new_meta_path = dir.join(format!("{new_label}.meta"));
649    let mut new_meta_json: Option<String> = None;
650    if new_meta_path.exists() {
651        let content = read_to_string_no_follow(&new_meta_path)?;
652        let mut meta: KeyMeta =
653            serde_json::from_str(&content).map_err(|e| Error::Serialization(e.to_string()))?;
654        meta.label = new_label.to_string();
655        let json =
656            serde_json::to_string_pretty(&meta).map_err(|e| Error::Serialization(e.to_string()))?;
657        if let Err(err) = metadata_writer(&new_meta_path, json.as_bytes()) {
658            rollback_renames(&renamed)?;
659            return Err(err);
660        }
661        new_meta_json = Some(json);
662    }
663    // Recompute and rewrite the HMAC sidecar against the new meta.
664    // The old sidecar (still under the old label name) is unlinked
665    // unconditionally so a stale sidecar can't be picked up later.
666    if old_hmac.exists() {
667        drop(std::fs::remove_file(&old_hmac));
668    }
669    if let (Some(json), Some(key)) = (new_meta_json.as_ref(), hmac_key) {
670        let new_hmac = dir.join(format!("{new_label}.meta.hmac"));
671        let tag = compute_meta_hmac(key, json.as_bytes());
672        if let Err(err) = metadata_writer(&new_hmac, tag.as_bytes()) {
673            rollback_renames(&renamed)?;
674            return Err(err);
675        }
676    }
677    Ok(())
678}
679
680fn rollback_renames(renamed: &[(PathBuf, PathBuf)]) -> Result<()> {
681    for (old, new) in renamed.iter().rev() {
682        if new.exists() {
683            std::fs::rename(new, old)?;
684        }
685    }
686    Ok(())
687}
688
689#[cfg(test)]
690#[allow(
691    clippy::unwrap_used,
692    clippy::panic,
693    clippy::used_underscore_binding,
694    let_underscore_drop
695)]
696mod tests {
697    use super::*;
698    use crate::internal::core::{AccessPolicy, KeyType};
699    use std::sync::atomic::{AtomicU64, Ordering};
700
701    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
702
703    fn test_dir() -> PathBuf {
704        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
705        let pid = std::process::id();
706        let dir = std::env::temp_dir().join(format!("enclaveapp-core-test-{pid}-{id}"));
707        std::fs::create_dir_all(&dir).unwrap();
708        dir
709    }
710
711    #[test]
712    fn meta_hmac_roundtrip_accepts_unchanged_meta() {
713        let dir = test_dir();
714        let hmac_key = b"test-hmac-key-material-32-bytes!";
715        let meta = KeyMeta::new(
716            "roundtrip",
717            KeyType::Encryption,
718            AccessPolicy::BiometricOnly,
719        );
720        save_meta_with_hmac(&dir, "roundtrip", &meta, hmac_key).unwrap();
721        let loaded = load_meta_with_hmac(
722            &dir,
723            "roundtrip",
724            hmac_key,
725            MetaIntegrityMode::RequireSidecar,
726        )
727        .unwrap();
728        assert_eq!(loaded.access_policy, AccessPolicy::BiometricOnly);
729        assert_eq!(loaded.label, "roundtrip");
730        std::fs::remove_dir_all(&dir).unwrap();
731    }
732
733    #[test]
734    fn meta_hmac_rejects_tampered_meta() {
735        let dir = test_dir();
736        let hmac_key = b"test-hmac-key-material-32-bytes!";
737        let meta = KeyMeta::new("tamper", KeyType::Encryption, AccessPolicy::BiometricOnly);
738        save_meta_with_hmac(&dir, "tamper", &meta, hmac_key).unwrap();
739
740        // Rewrite .meta to flip AccessPolicy → None, leaving the HMAC sidecar untouched.
741        let meta_path = dir.join("tamper.meta");
742        let raw = std::fs::read_to_string(&meta_path).unwrap();
743        let tampered = raw.replace("biometric_only", "none");
744        std::fs::write(&meta_path, tampered).unwrap();
745
746        let err = load_meta_with_hmac(&dir, "tamper", hmac_key, MetaIntegrityMode::RequireSidecar)
747            .unwrap_err();
748        assert!(
749            err.to_string().contains(META_HMAC_VERIFY_OP),
750            "expected HMAC-verify failure, got: {err}"
751        );
752        std::fs::remove_dir_all(&dir).unwrap();
753    }
754
755    #[test]
756    fn meta_hmac_rejects_wrong_key() {
757        let dir = test_dir();
758        let hmac_key = b"test-hmac-key-material-32-bytes!";
759        let meta = KeyMeta::new("wrongkey", KeyType::Encryption, AccessPolicy::None);
760        save_meta_with_hmac(&dir, "wrongkey", &meta, hmac_key).unwrap();
761
762        let bad_key = b"different-hmac-key-material-32by";
763        let err = load_meta_with_hmac(&dir, "wrongkey", bad_key, MetaIntegrityMode::RequireSidecar)
764            .unwrap_err();
765        assert!(err.to_string().contains(META_HMAC_VERIFY_OP));
766        std::fs::remove_dir_all(&dir).unwrap();
767    }
768
769    #[test]
770    fn meta_hmac_legacy_mode_accepts_missing_sidecar() {
771        // Legacy caches saved before the sidecar shipped must load OK
772        // in legacy/migration mode.
773        let dir = test_dir();
774        let hmac_key = b"test-hmac-key-material-32-bytes!";
775        let meta = KeyMeta::new("legacy", KeyType::Signing, AccessPolicy::None);
776        save_meta(&dir, "legacy", &meta).unwrap(); // no sidecar
777        let loaded = load_meta_with_hmac(
778            &dir,
779            "legacy",
780            hmac_key,
781            MetaIntegrityMode::AllowLegacyMissingSidecar,
782        )
783        .unwrap();
784        assert_eq!(loaded.label, "legacy");
785        std::fs::remove_dir_all(&dir).unwrap();
786    }
787
788    #[test]
789    fn meta_hmac_strict_rejects_missing_sidecar() {
790        // Same scenario as the legacy test, but in strict mode the
791        // load must fail — otherwise an attacker who deletes the
792        // sidecar bypasses HMAC verification entirely.
793        let dir = test_dir();
794        let hmac_key = b"test-hmac-key-material-32-bytes!";
795        let meta = KeyMeta::new("legacy", KeyType::Signing, AccessPolicy::None);
796        save_meta(&dir, "legacy", &meta).unwrap();
797        let err = load_meta_with_hmac(&dir, "legacy", hmac_key, MetaIntegrityMode::RequireSidecar)
798            .unwrap_err();
799        assert!(
800            err.to_string().contains(META_HMAC_MISSING_OP),
801            "expected meta_hmac_missing, got: {err}"
802        );
803        std::fs::remove_dir_all(&dir).unwrap();
804    }
805
806    #[test]
807    fn migrate_meta_to_hmac_writes_sidecar_for_legacy_meta() {
808        let dir = test_dir();
809        let hmac_key = b"test-hmac-key-material-32-bytes!";
810        let meta = KeyMeta::new("legacy", KeyType::Signing, AccessPolicy::None);
811        save_meta(&dir, "legacy", &meta).unwrap();
812        assert!(!dir.join("legacy.meta.hmac").exists());
813
814        migrate_meta_to_hmac(&dir, "legacy", hmac_key).unwrap();
815        assert!(dir.join("legacy.meta.hmac").exists());
816
817        // After migration, strict load succeeds.
818        let loaded =
819            load_meta_with_hmac(&dir, "legacy", hmac_key, MetaIntegrityMode::RequireSidecar)
820                .unwrap();
821        assert_eq!(loaded.label, "legacy");
822        std::fs::remove_dir_all(&dir).unwrap();
823    }
824
825    #[test]
826    fn migrate_meta_to_hmac_errors_for_missing_meta() {
827        let dir = test_dir();
828        let hmac_key = b"test-hmac-key-material-32-bytes!";
829        let err = migrate_meta_to_hmac(&dir, "ghost", hmac_key).unwrap_err();
830        assert!(matches!(err, Error::KeyNotFound { .. }));
831        std::fs::remove_dir_all(&dir).unwrap();
832    }
833
834    #[test]
835    fn compute_meta_hmac_is_stable() {
836        // HMAC-SHA256 of an empty message under an empty key, from RFC 4231
837        // test vector 1 isn't directly applicable (uses 20-byte key), so we
838        // just assert our function is deterministic.
839        let key = b"k";
840        let data = b"message";
841        let a = compute_meta_hmac(key, data);
842        let b = compute_meta_hmac(key, data);
843        assert_eq!(a, b);
844        assert_eq!(a.len(), 64); // 32 bytes hex-encoded
845    }
846
847    #[test]
848    fn constant_time_eq_rejects_length_mismatch() {
849        assert!(!constant_time_eq(b"abc", b"abcd"));
850        assert!(constant_time_eq(b"abc", b"abc"));
851        assert!(!constant_time_eq(b"abc", b"abd"));
852    }
853
854    #[test]
855    fn constant_time_eq_empty_slices_are_equal() {
856        assert!(constant_time_eq(b"", b""));
857    }
858
859    #[test]
860    fn compute_meta_hmac_bytes_output_is_32_bytes() {
861        let tag = compute_meta_hmac_bytes(b"k", b"d");
862        assert_eq!(tag.len(), 32);
863    }
864
865    #[test]
866    fn compute_meta_hmac_bytes_is_deterministic() {
867        let key = b"stable-key";
868        let data = b"stable-data";
869        let a = compute_meta_hmac_bytes(key, data);
870        let b = compute_meta_hmac_bytes(key, data);
871        assert_eq!(a, b);
872    }
873
874    #[test]
875    fn compute_meta_hmac_bytes_long_key_exercises_hash_path() {
876        // key > 64 bytes (SHA-256 block size) triggers the hash-then-pad branch
877        let long_key = vec![0x5a_u8; 128];
878        let short_key = &long_key[..8];
879        let data = b"test-data";
880        let long_tag = compute_meta_hmac_bytes(&long_key, data);
881        let short_tag = compute_meta_hmac_bytes(short_key, data);
882        assert_ne!(long_tag, short_tag);
883        assert_eq!(long_tag.len(), 32);
884    }
885
886    #[test]
887    fn compute_meta_hmac_bytes_different_data_produces_different_tag() {
888        let key = b"same-key";
889        let tag_a = compute_meta_hmac_bytes(key, b"data-a");
890        let tag_b = compute_meta_hmac_bytes(key, b"data-b");
891        assert_ne!(tag_a, tag_b);
892    }
893
894    #[test]
895    fn compute_meta_hmac_bytes_different_key_produces_different_tag() {
896        let data = b"same-data";
897        let tag_a = compute_meta_hmac_bytes(b"key-a", data);
898        let tag_b = compute_meta_hmac_bytes(b"key-b", data);
899        assert_ne!(tag_a, tag_b);
900    }
901
902    #[test]
903    fn key_meta_new_sets_timestamp() {
904        let meta = KeyMeta::new("test", KeyType::Signing, AccessPolicy::None);
905        assert_eq!(meta.label, "test");
906        assert_eq!(meta.key_type, KeyType::Signing);
907        assert!(!meta.created.is_empty());
908        let ts: u64 = meta.created.parse().unwrap();
909        assert!(ts > 0);
910    }
911
912    #[test]
913    fn key_meta_clone_preserves_all_fields() {
914        let mut meta = KeyMeta::new(
915            "clone-test",
916            KeyType::Encryption,
917            AccessPolicy::BiometricOnly,
918        );
919        meta.set_app_field("field", "value");
920        let cloned = meta.clone();
921        assert_eq!(cloned.label, meta.label);
922        assert_eq!(cloned.key_type, meta.key_type);
923        assert_eq!(cloned.access_policy, meta.access_policy);
924        assert_eq!(cloned.get_app_field("field"), Some("value"));
925    }
926
927    #[test]
928    fn key_meta_app_field_roundtrip() {
929        let mut meta = KeyMeta::new("test", KeyType::Signing, AccessPolicy::None);
930        assert!(meta.get_app_field("git_email").is_none());
931        meta.set_app_field("git_email", "jay@example.com");
932        assert_eq!(meta.get_app_field("git_email"), Some("jay@example.com"));
933    }
934
935    #[test]
936    fn key_meta_serde_roundtrip() {
937        let mut meta = KeyMeta::new("test", KeyType::Encryption, AccessPolicy::BiometricOnly);
938        meta.set_app_field("profile", "default");
939        let json = serde_json::to_string_pretty(&meta).unwrap();
940        let parsed: KeyMeta = serde_json::from_str(&json).unwrap();
941        assert_eq!(parsed.label, "test");
942        assert_eq!(parsed.key_type, KeyType::Encryption);
943        assert_eq!(parsed.access_policy, AccessPolicy::BiometricOnly);
944        assert_eq!(parsed.get_app_field("profile"), Some("default"));
945    }
946
947    #[test]
948    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
949    fn atomic_write_creates_file() {
950        let dir = test_dir();
951        let path = dir.join("test.txt");
952        atomic_write(&path, b"hello world").unwrap();
953        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
954        std::fs::remove_dir_all(&dir).unwrap();
955    }
956
957    #[test]
958    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
959    fn atomic_write_ignores_preexisting_legacy_tmp_file() {
960        let dir = test_dir();
961        let path = dir.join("test.txt");
962        let legacy_tmp = path.with_extension("tmp");
963        std::fs::write(&legacy_tmp, b"legacy").unwrap();
964
965        atomic_write(&path, b"fresh").unwrap();
966
967        assert_eq!(std::fs::read_to_string(&path).unwrap(), "fresh");
968        assert_eq!(std::fs::read_to_string(&legacy_tmp).unwrap(), "legacy");
969        std::fs::remove_dir_all(&dir).unwrap();
970    }
971
972    #[test]
973    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
974    fn atomic_write_syncs_parent_directory_after_rename() {
975        use std::sync::atomic::{AtomicBool, Ordering};
976
977        let dir = test_dir();
978        let path = dir.join("test.txt");
979        let synced = AtomicBool::new(false);
980
981        atomic_write_with_sync(&path, b"hello world", |parent| {
982            assert_eq!(parent, dir.as_path());
983            synced.store(true, Ordering::SeqCst);
984            Ok(())
985        })
986        .unwrap();
987
988        assert!(synced.load(Ordering::SeqCst));
989        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
990        std::fs::remove_dir_all(&dir).unwrap();
991    }
992
993    #[test]
994    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
995    fn read_no_follow_reads_file_content() {
996        let dir = test_dir();
997        let path = dir.join("data.bin");
998        std::fs::write(&path, b"hello bytes").unwrap();
999        let result = read_no_follow(&path).unwrap();
1000        assert_eq!(result, b"hello bytes");
1001        std::fs::remove_dir_all(&dir).unwrap();
1002    }
1003
1004    #[test]
1005    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1006    fn read_no_follow_returns_error_for_missing_file() {
1007        let dir = test_dir();
1008        let path = dir.join("nonexistent.bin");
1009        let result = read_no_follow(&path);
1010        assert!(result.is_err());
1011        std::fs::remove_dir_all(&dir).unwrap();
1012    }
1013
1014    #[test]
1015    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1016    fn save_load_meta_roundtrip() {
1017        let dir = test_dir();
1018        let meta = KeyMeta::new("mykey", KeyType::Signing, AccessPolicy::Any);
1019        save_meta(&dir, "mykey", &meta).unwrap();
1020        let loaded = load_meta(&dir, "mykey").unwrap();
1021        assert_eq!(loaded.label, "mykey");
1022        assert_eq!(loaded.key_type, KeyType::Signing);
1023        assert_eq!(loaded.access_policy, AccessPolicy::Any);
1024        std::fs::remove_dir_all(&dir).unwrap();
1025    }
1026
1027    #[test]
1028    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1029    fn load_meta_returns_default_for_missing() {
1030        let dir = test_dir();
1031        let meta = load_meta(&dir, "nonexistent").unwrap();
1032        assert_eq!(meta.label, "nonexistent");
1033        assert_eq!(meta.key_type, KeyType::Signing);
1034        std::fs::remove_dir_all(&dir).unwrap();
1035    }
1036
1037    #[test]
1038    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1039    fn save_load_pub_key_roundtrip() {
1040        let dir = test_dir();
1041        let pub_key = vec![0x04; 65];
1042        save_pub_key(&dir, "mykey", &pub_key).unwrap();
1043        let loaded = load_pub_key(&dir, "mykey").unwrap();
1044        assert_eq!(loaded, pub_key);
1045        std::fs::remove_dir_all(&dir).unwrap();
1046    }
1047
1048    #[test]
1049    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1050    fn load_pub_key_returns_key_not_found() {
1051        let dir = test_dir();
1052        let err = load_pub_key(&dir, "missing").unwrap_err();
1053        match err {
1054            Error::KeyNotFound { label } => assert_eq!(label, "missing"),
1055            other => panic!("expected KeyNotFound, got: {other}"),
1056        }
1057        std::fs::remove_dir_all(&dir).unwrap();
1058    }
1059
1060    #[test]
1061    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1062    fn sync_pub_key_writes_missing_cache() {
1063        let dir = test_dir();
1064        let pub_key = vec![0x04; 65];
1065
1066        let synced = sync_pub_key(&dir, "sync", &pub_key).unwrap();
1067        assert_eq!(synced, pub_key);
1068        assert_eq!(load_pub_key(&dir, "sync").unwrap(), pub_key);
1069
1070        std::fs::remove_dir_all(&dir).unwrap();
1071    }
1072
1073    #[test]
1074    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1075    fn sync_pub_key_repairs_mismatched_cache() {
1076        let dir = test_dir();
1077        let mut authoritative = vec![0x04];
1078        authoritative.extend_from_slice(&[0x11; 64]);
1079
1080        save_pub_key(&dir, "sync", &[0x04; 65]).unwrap();
1081
1082        let synced = sync_pub_key(&dir, "sync", &authoritative).unwrap();
1083        assert_eq!(synced, authoritative);
1084        assert_eq!(load_pub_key(&dir, "sync").unwrap(), authoritative);
1085
1086        std::fs::remove_dir_all(&dir).unwrap();
1087    }
1088
1089    #[test]
1090    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1091    fn metadata_label_operations_reject_invalid_labels() {
1092        let dir = test_dir();
1093        let meta = KeyMeta::new("valid", KeyType::Signing, AccessPolicy::None);
1094
1095        let err = save_meta(&dir, "../escape", &meta).unwrap_err();
1096        assert!(matches!(err, Error::InvalidLabel { .. }));
1097
1098        let err = load_meta(&dir, "../escape").unwrap_err();
1099        assert!(matches!(err, Error::InvalidLabel { .. }));
1100
1101        let err = save_pub_key(&dir, "../escape", b"pubkey").unwrap_err();
1102        assert!(matches!(err, Error::InvalidLabel { .. }));
1103
1104        let err = load_pub_key(&dir, "../escape").unwrap_err();
1105        assert!(matches!(err, Error::InvalidLabel { .. }));
1106
1107        let err = delete_key_files(&dir, "../escape").unwrap_err();
1108        assert!(matches!(err, Error::InvalidLabel { .. }));
1109
1110        let err = rename_key_files(&dir, "valid", "../escape", None).unwrap_err();
1111        assert!(matches!(err, Error::InvalidLabel { .. }));
1112
1113        std::fs::remove_dir_all(&dir).unwrap();
1114    }
1115
1116    #[test]
1117    #[cfg_attr(miri, ignore)] // File I/O not supported under Miri isolation
1118    fn list_labels_empty_for_nonexistent_dir() {
1119        let dir = std::env::temp_dir().join("enclaveapp-core-test-nonexistent-dir");
1120        let _ = std::fs::remove_dir_all(&dir);
1121        let labels = list_labels(&dir).unwrap();
1122        assert!(labels.is_empty());
1123    }
1124
1125    #[test]
1126    #[cfg_attr(miri, ignore)] // File I/O + libc::umask not supported by Miri
1127    fn list_labels_finds_meta_files() {
1128        let dir = test_dir();
1129        let meta_a = KeyMeta::new("alpha", KeyType::Signing, AccessPolicy::None);
1130        let meta_b = KeyMeta::new("beta", KeyType::Encryption, AccessPolicy::Any);
1131        save_meta(&dir, "alpha", &meta_a).unwrap();
1132        save_meta(&dir, "beta", &meta_b).unwrap();
1133        // Also create a .pub file that should be ignored
1134        std::fs::write(dir.join("alpha.pub"), b"pubkey").unwrap();
1135        let labels = list_labels(&dir).unwrap();
1136        assert_eq!(labels, vec!["alpha", "beta"]);
1137        std::fs::remove_dir_all(&dir).unwrap();
1138    }
1139
1140    #[test]
1141    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1142    fn list_labels_for_extensions_includes_unique_sorted_stems() {
1143        let dir = test_dir();
1144        std::fs::write(dir.join("alpha.handle"), b"handle").unwrap();
1145        std::fs::write(dir.join("beta.meta"), b"{}").unwrap();
1146        std::fs::write(dir.join("beta.handle"), b"handle").unwrap();
1147        std::fs::write(dir.join("gamma.pub"), b"pub").unwrap();
1148
1149        let labels = list_labels_for_extensions(&dir, &["meta", "handle"]).unwrap();
1150        assert_eq!(labels, vec!["alpha", "beta"]);
1151
1152        std::fs::remove_dir_all(&dir).unwrap();
1153    }
1154
1155    #[test]
1156    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1157    fn list_labels_for_extensions_skips_invalid_labels() {
1158        let dir = test_dir();
1159        std::fs::write(dir.join("valid.handle"), b"handle").unwrap();
1160        std::fs::write(dir.join("bad label.handle"), b"handle").unwrap();
1161        std::fs::write(dir.join("also.bad.handle"), b"handle").unwrap();
1162
1163        let labels = list_labels_for_extensions(&dir, &["handle"]).unwrap();
1164        assert_eq!(labels, vec!["valid"]);
1165
1166        std::fs::remove_dir_all(&dir).unwrap();
1167    }
1168
1169    #[test]
1170    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1171    fn delete_key_files_removes_all() {
1172        let dir = test_dir();
1173        std::fs::write(dir.join("mykey.meta"), b"{}").unwrap();
1174        std::fs::write(dir.join("mykey.pub"), b"pub").unwrap();
1175        std::fs::write(dir.join("mykey.handle"), b"handle").unwrap();
1176        delete_key_files(&dir, "mykey").unwrap();
1177        assert!(!dir.join("mykey.meta").exists());
1178        assert!(!dir.join("mykey.pub").exists());
1179        assert!(!dir.join("mykey.handle").exists());
1180        std::fs::remove_dir_all(&dir).unwrap();
1181    }
1182
1183    #[test]
1184    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1185    fn delete_key_files_returns_key_not_found() {
1186        let dir = test_dir();
1187        let err = delete_key_files(&dir, "ghost").unwrap_err();
1188        match err {
1189            Error::KeyNotFound { label } => assert_eq!(label, "ghost"),
1190            other => panic!("expected KeyNotFound, got: {other}"),
1191        }
1192        std::fs::remove_dir_all(&dir).unwrap();
1193    }
1194
1195    #[test]
1196    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1197    fn rename_key_files_renames_and_updates_meta() {
1198        let dir = test_dir();
1199        let meta = KeyMeta::new("old-name", KeyType::Signing, AccessPolicy::None);
1200        save_meta(&dir, "old-name", &meta).unwrap();
1201        save_pub_key(&dir, "old-name", b"pubkey").unwrap();
1202
1203        rename_key_files(&dir, "old-name", "new-name", None).unwrap();
1204
1205        assert!(!dir.join("old-name.meta").exists());
1206        assert!(!dir.join("old-name.pub").exists());
1207        assert!(dir.join("new-name.meta").exists());
1208        assert!(dir.join("new-name.pub").exists());
1209
1210        let loaded = load_meta(&dir, "new-name").unwrap();
1211        assert_eq!(loaded.label, "new-name");
1212        std::fs::remove_dir_all(&dir).unwrap();
1213    }
1214
1215    #[test]
1216    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1217    fn rename_key_files_rejects_existing_target() {
1218        let dir = test_dir();
1219        let meta = KeyMeta::new("src", KeyType::Signing, AccessPolicy::None);
1220        save_meta(&dir, "src", &meta).unwrap();
1221        let meta2 = KeyMeta::new("dst", KeyType::Signing, AccessPolicy::None);
1222        save_meta(&dir, "dst", &meta2).unwrap();
1223
1224        let err = rename_key_files(&dir, "src", "dst", None).unwrap_err();
1225        match err {
1226            Error::DuplicateLabel { label } => assert_eq!(label, "dst"),
1227            other => panic!("expected DuplicateLabel, got: {other}"),
1228        }
1229        std::fs::remove_dir_all(&dir).unwrap();
1230    }
1231
1232    #[test]
1233    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1234    fn rename_key_files_rejects_existing_target_pub_without_meta() {
1235        let dir = test_dir();
1236        let meta = KeyMeta::new("src", KeyType::Signing, AccessPolicy::None);
1237        save_meta(&dir, "src", &meta).unwrap();
1238        save_pub_key(&dir, "dst", b"existing").unwrap();
1239
1240        let err = rename_key_files(&dir, "src", "dst", None).unwrap_err();
1241        match err {
1242            Error::DuplicateLabel { label } => assert_eq!(label, "dst"),
1243            other => panic!("expected DuplicateLabel, got: {other}"),
1244        }
1245        assert!(dir.join("src.meta").exists());
1246        assert!(dir.join("dst.pub").exists());
1247        std::fs::remove_dir_all(&dir).unwrap();
1248    }
1249
1250    #[test]
1251    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1252    fn rename_key_files_rolls_back_when_metadata_update_fails() {
1253        let dir = test_dir();
1254        let meta = KeyMeta::new("old-name", KeyType::Signing, AccessPolicy::None);
1255        save_meta(&dir, "old-name", &meta).unwrap();
1256        save_pub_key(&dir, "old-name", b"pubkey").unwrap();
1257
1258        let err = rename_key_files_with_writer(&dir, "old-name", "new-name", None, |_, _| {
1259            Err(Error::Serialization("forced failure".into()))
1260        })
1261        .unwrap_err();
1262        assert!(matches!(err, Error::Serialization(_)));
1263        assert!(dir.join("old-name.meta").exists());
1264        assert!(dir.join("old-name.pub").exists());
1265        assert!(!dir.join("new-name.meta").exists());
1266        assert!(!dir.join("new-name.pub").exists());
1267        let loaded = load_meta(&dir, "old-name").unwrap();
1268        assert_eq!(loaded.label, "old-name");
1269        std::fs::remove_dir_all(&dir).unwrap();
1270    }
1271
1272    #[test]
1273    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1274    fn rename_key_files_with_sidecar_recomputes_hmac_under_new_label() {
1275        let dir = test_dir();
1276        let hmac_key = b"test-hmac-key-material-32-bytes!";
1277        let meta = KeyMeta::new("old-name", KeyType::Signing, AccessPolicy::None);
1278        save_meta_with_hmac(&dir, "old-name", &meta, hmac_key).unwrap();
1279        save_pub_key(&dir, "old-name", b"pubkey").unwrap();
1280
1281        rename_key_files(&dir, "old-name", "new-name", Some(hmac_key)).unwrap();
1282
1283        assert!(!dir.join("old-name.meta").exists());
1284        assert!(!dir.join("old-name.meta.hmac").exists());
1285        assert!(dir.join("new-name.meta").exists());
1286        assert!(dir.join("new-name.meta.hmac").exists());
1287
1288        // Strict load against the new label must succeed — i.e. the
1289        // sidecar was rewritten to authenticate the relabeled meta,
1290        // not left over as the old-name HMAC.
1291        let loaded = load_meta_with_hmac(
1292            &dir,
1293            "new-name",
1294            hmac_key,
1295            MetaIntegrityMode::RequireSidecar,
1296        )
1297        .unwrap();
1298        assert_eq!(loaded.label, "new-name");
1299        std::fs::remove_dir_all(&dir).unwrap();
1300    }
1301
1302    #[test]
1303    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1304    fn rename_key_files_with_sidecar_requires_hmac_key() {
1305        // If the .meta.hmac sidecar is present but the caller forgot
1306        // to pass the hmac_key, refuse the rename rather than leaving
1307        // a stale or orphaned sidecar.
1308        let dir = test_dir();
1309        let hmac_key = b"test-hmac-key-material-32-bytes!";
1310        let meta = KeyMeta::new("old-name", KeyType::Signing, AccessPolicy::None);
1311        save_meta_with_hmac(&dir, "old-name", &meta, hmac_key).unwrap();
1312
1313        let err = rename_key_files(&dir, "old-name", "new-name", None).unwrap_err();
1314        match err {
1315            Error::KeyOperation { operation, .. } => assert_eq!(operation, "rename_key_files"),
1316            other => panic!("expected KeyOperation, got: {other}"),
1317        }
1318        // No partial rename left behind.
1319        assert!(dir.join("old-name.meta").exists());
1320        assert!(dir.join("old-name.meta.hmac").exists());
1321        assert!(!dir.join("new-name.meta").exists());
1322        std::fs::remove_dir_all(&dir).unwrap();
1323    }
1324
1325    #[test]
1326    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1327    fn rename_key_files_rejects_missing_source() {
1328        let dir = test_dir();
1329        let err = rename_key_files(&dir, "missing", "new", None).unwrap_err();
1330        match err {
1331            Error::KeyNotFound { label } => assert_eq!(label, "missing"),
1332            other => panic!("expected KeyNotFound, got: {other}"),
1333        }
1334        std::fs::remove_dir_all(&dir).unwrap();
1335    }
1336
1337    #[test]
1338    #[cfg_attr(miri, ignore)] // dirs::data_dir() calls FFI not supported by Miri
1339    fn keys_dir_returns_absolute_path() {
1340        let dir = keys_dir("test-app");
1341        assert!(dir.is_absolute());
1342        assert!(dir.to_string_lossy().contains("test-app"));
1343        assert!(dir.to_string_lossy().contains("keys"));
1344    }
1345
1346    #[test]
1347    #[cfg_attr(miri, ignore)] // dirs::config_dir() calls FFI not supported by Miri
1348    fn config_dir_returns_absolute_path() {
1349        let dir = config_dir("test-app");
1350        assert!(dir.is_absolute());
1351        assert!(dir.to_string_lossy().contains("test-app"));
1352    }
1353
1354    #[test]
1355    #[cfg_attr(miri, ignore)] // File I/O not supported by Miri isolation
1356    fn ensure_dir_creates_nested() {
1357        let dir = test_dir();
1358        let nested = dir.join("a").join("b").join("c");
1359        ensure_dir(&nested).unwrap();
1360        assert!(nested.exists());
1361        assert!(nested.is_dir());
1362        std::fs::remove_dir_all(&dir).unwrap();
1363    }
1364
1365    #[test]
1366    #[cfg_attr(miri, ignore)] // File I/O (mkdir) not supported under Miri isolation
1367    fn dir_lock_acquire_and_drop() {
1368        let dir = test_dir();
1369        std::fs::create_dir_all(&dir).unwrap();
1370        let _lock = DirLock::acquire(&dir).unwrap();
1371        assert!(dir.join(".lock").exists());
1372        drop(_lock);
1373        std::fs::remove_dir_all(&dir).unwrap();
1374    }
1375
1376    #[test]
1377    #[cfg_attr(miri, ignore)] // Threaded file locking not supported under Miri isolation
1378    fn dir_lock_blocks_until_first_holder_releases() {
1379        use std::sync::mpsc;
1380        use std::thread;
1381        use std::time::{Duration, Instant};
1382
1383        let dir = test_dir();
1384        std::fs::create_dir_all(&dir).unwrap();
1385        let first = DirLock::acquire(&dir).unwrap();
1386        let (tx, rx) = mpsc::channel();
1387        let thread_dir = dir.clone();
1388
1389        let handle = thread::spawn(move || {
1390            tx.send(Instant::now()).unwrap();
1391            let _second = DirLock::acquire(&thread_dir).unwrap();
1392            tx.send(Instant::now()).unwrap();
1393        });
1394
1395        let start = rx.recv().unwrap();
1396        thread::sleep(Duration::from_millis(150));
1397        drop(first);
1398        let acquired = rx.recv().unwrap();
1399        assert!(acquired.duration_since(start) >= Duration::from_millis(100));
1400        handle.join().unwrap();
1401        std::fs::remove_dir_all(&dir).unwrap();
1402    }
1403
1404    #[test]
1405    #[cfg_attr(miri, ignore)] // libc::chmod not supported by Miri
1406    fn restrict_file_permissions_succeeds() {
1407        let dir = test_dir();
1408        let path = dir.join("secret.txt");
1409        std::fs::write(&path, b"secret").unwrap();
1410        restrict_file_permissions(&path).unwrap();
1411        #[cfg(unix)]
1412        {
1413            use std::os::unix::fs::PermissionsExt;
1414            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1415            assert_eq!(mode, 0o600);
1416        }
1417        std::fs::remove_dir_all(&dir).unwrap();
1418    }
1419}