Skip to main content

agent_engine/runtime/trace/
key.rs

1//! Installation-scoped digest key + keyed component digests.
2//!
3//! The HMAC key is random per installation, 32 bytes, stored `0600` (parent
4//! `0700`), symlink-refusing and regular-file-only on both the read and the
5//! create path. Neither the key nor digest preimages are ever logged.
6
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10// --- Keyed component digest ---
11
12/// Lowercase-hex HMAC-SHA256 output (exactly 64 hex chars). The validated
13/// newtype makes it impossible to smuggle raw content through a digest field:
14/// construction is only via [`keyed_digest`] or by parsing a well-formed hex
15/// string.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(try_from = "String", into = "String")]
18pub struct ComponentDigest(String);
19
20impl ComponentDigest {
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl TryFrom<String> for ComponentDigest {
27    type Error = String;
28    fn try_from(value: String) -> Result<Self, Self::Error> {
29        if value.len() == 64
30            && value
31                .bytes()
32                .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
33        {
34            Ok(ComponentDigest(value))
35        } else {
36            Err("component digest must be exactly 64 lowercase hex chars".to_string())
37        }
38    }
39}
40
41impl From<ComponentDigest> for String {
42    fn from(value: ComponentDigest) -> Self {
43        value.0
44    }
45}
46
47/// Domain-separation label for keyed digests, so a digest of e.g. a system
48/// segment can never be cross-matched against a tool schema digest of the
49/// same bytes.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum DigestDomain {
53    Wire,
54    SystemSegment,
55    MessageBlock,
56    ToolSchema,
57    ToolsPrefix,
58    SystemPrefix,
59    HistoryTail,
60}
61
62impl DigestDomain {
63    fn label(self) -> &'static [u8] {
64        match self {
65            DigestDomain::Wire => b"synaps-trace:wire\0",
66            DigestDomain::SystemSegment => b"synaps-trace:system-segment\0",
67            DigestDomain::MessageBlock => b"synaps-trace:message-block\0",
68            DigestDomain::ToolSchema => b"synaps-trace:tool-schema\0",
69            DigestDomain::ToolsPrefix => b"synaps-trace:tools-prefix\0",
70            DigestDomain::SystemPrefix => b"synaps-trace:system-prefix\0",
71            DigestDomain::HistoryTail => b"synaps-trace:history-tail\0",
72        }
73    }
74}
75
76// --- Installation-scoped digest key ---
77
78/// Random 32-byte per-installation HMAC key. Zeroized on drop; `Debug` is
79/// redacted so the key never reaches a log line by accident.
80pub struct TraceDigestKey(zeroize::Zeroizing<[u8; 32]>);
81
82impl std::fmt::Debug for TraceDigestKey {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str("TraceDigestKey(..)")
85    }
86}
87
88/// Typed failure for digest-key loading.
89#[derive(Debug)]
90pub enum TraceKeyError {
91    /// A symlink is planted at the key path — refusing to read or create
92    /// through it.
93    SymlinkRefused(PathBuf),
94    /// The key path exists but is not a regular file (FIFO, device,
95    /// directory, socket, …) — refusing to open or read it.
96    NotRegularFile(PathBuf),
97    /// The key file exists but does not contain exactly 32 bytes.
98    Corrupt(PathBuf),
99    Io(std::io::Error),
100}
101
102impl std::fmt::Display for TraceKeyError {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            TraceKeyError::SymlinkRefused(p) => {
106                write!(f, "refusing symlink at trace key path {}", p.display())
107            }
108            TraceKeyError::NotRegularFile(p) => {
109                write!(f, "trace key path {} is not a regular file", p.display())
110            }
111            TraceKeyError::Corrupt(p) => {
112                write!(f, "trace digest key at {} is corrupt", p.display())
113            }
114            TraceKeyError::Io(e) => write!(f, "trace digest key io error: {e}"),
115        }
116    }
117}
118
119impl std::error::Error for TraceKeyError {}
120
121impl From<std::io::Error> for TraceKeyError {
122    fn from(e: std::io::Error) -> Self {
123        TraceKeyError::Io(e)
124    }
125}
126
127/// Default installation-scoped key path: `<synaps base dir>/trace/digest.key`.
128pub fn default_digest_key_path() -> PathBuf {
129    agent_core::core::config::base_dir()
130        .join("trace")
131        .join("digest.key")
132}
133
134/// Load the installation digest key, creating it on first use.
135pub fn load_or_create_digest_key() -> Result<TraceDigestKey, TraceKeyError> {
136    load_or_create_digest_key_at(&default_digest_key_path())
137}
138
139/// Load (or atomically create) the digest key at `path`.
140///
141/// Guarantees:
142/// - the parent directory is created `0700` via the Phase 1 private-fs
143///   helper; the key file is created `0600` with no broader interval
144///   (mode set at `open`, never create-then-chmod);
145/// - a symlink planted at the key path is refused, never followed;
146/// - a non-regular file (FIFO, device, directory) at the key path is refused
147///   with [`TraceKeyError::NotRegularFile`], promptly and without blocking;
148/// - a pre-existing key file with a broader mode is repaired to exactly
149///   `0600` via the already-open handle (no path re-resolution);
150/// - at most 33 bytes are read; any length other than exactly 32 is
151///   [`TraceKeyError::Corrupt`];
152/// - concurrent first-time creation converges: the key is fully written to a
153///   `0600` temp file and published with an atomic `link(2)`; losers read the
154///   winner's key, so every caller observes the same key bytes.
155pub fn load_or_create_digest_key_at(path: &Path) -> Result<TraceDigestKey, TraceKeyError> {
156    use std::io::Read as _;
157    use std::io::Write as _;
158
159    let parent = path.parent().ok_or_else(|| {
160        TraceKeyError::Io(std::io::Error::new(
161            std::io::ErrorKind::InvalidInput,
162            "trace key path has no parent directory",
163        ))
164    })?;
165    agent_core::core::private_fs::ensure_private_dir(parent).map_err(std::io::Error::from)?;
166
167    // Bounded retry: each iteration either reads an existing complete key or
168    // attempts to publish a fresh one.
169    for _ in 0..8 {
170        match std::fs::symlink_metadata(path) {
171            Ok(meta) if meta.file_type().is_symlink() => {
172                return Err(TraceKeyError::SymlinkRefused(path.to_path_buf()));
173            }
174            // Pre-open gate: anything that is not a regular file (FIFO,
175            // device, directory, socket) is refused before we ever open it,
176            // so a planted FIFO can never block us.
177            Ok(meta) if !meta.file_type().is_file() => {
178                return Err(TraceKeyError::NotRegularFile(path.to_path_buf()));
179            }
180            Ok(_) => {
181                let file = open_nofollow_read(path)?;
182                // Post-open gate on the open handle (no path re-resolution):
183                // the fd itself must refer to a regular file, closing the
184                // check-then-open race.
185                let meta = file.metadata()?;
186                if !meta.file_type().is_file() {
187                    return Err(TraceKeyError::NotRegularFile(path.to_path_buf()));
188                }
189                // Repair a pre-existing broader mode to exactly 0600 via
190                // fchmod on the already-open handle.
191                #[cfg(unix)]
192                {
193                    use std::os::unix::fs::PermissionsExt as _;
194                    let mode = meta.permissions().mode() & 0o777;
195                    if mode != agent_core::core::private_fs::FILE_MODE {
196                        file.set_permissions(std::fs::Permissions::from_mode(
197                            agent_core::core::private_fs::FILE_MODE,
198                        ))?;
199                    }
200                }
201                // Bounded read: at most 33 bytes, so an oversized file can
202                // never allocate unboundedly. Anything other than exactly 32
203                // bytes is corrupt.
204                let mut buf = zeroize::Zeroizing::new(Vec::with_capacity(33));
205                file.take(33).read_to_end(&mut buf)?;
206                if buf.len() != 32 {
207                    return Err(TraceKeyError::Corrupt(path.to_path_buf()));
208                }
209                let mut key = zeroize::Zeroizing::new([0u8; 32]);
210                key.copy_from_slice(&buf);
211                return Ok(TraceDigestKey(key));
212            }
213            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
214            Err(e) => return Err(e.into()),
215        }
216
217        // Generate fresh key material from the OS CSPRNG.
218        let mut key = zeroize::Zeroizing::new([0u8; 32]);
219        {
220            use rand::TryRngCore as _;
221            rand::rngs::OsRng
222                .try_fill_bytes(key.as_mut())
223                .map_err(|e| TraceKeyError::Io(std::io::Error::other(e)))?;
224        }
225
226        // Write the full key to a private temp file, then publish atomically
227        // with link(2): the key path only ever exists complete, so a
228        // concurrent reader can never observe a partial key. The temp name is
229        // unique per attempt (pid + counter) so concurrent creators — threads
230        // or processes — never clobber each other's temp file.
231        static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
232        let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
233        let tmp = path.with_file_name(format!("digest.key.tmp.{}.{seq}", std::process::id()));
234        let publish = (|| -> Result<bool, TraceKeyError> {
235            let mut file = open_create_new_private(&tmp)?;
236            file.write_all(key.as_ref())?;
237            file.sync_all()?;
238            drop(file);
239            match std::fs::hard_link(&tmp, path) {
240                Ok(()) => Ok(true),
241                // Lost the race (or a symlink was planted): loop and re-read.
242                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
243                Err(e) => Err(e.into()),
244            }
245        })();
246        let _ = std::fs::remove_file(&tmp);
247        match publish {
248            Ok(true) => return Ok(TraceDigestKey(key)),
249            Ok(false) => continue,
250            Err(e) => return Err(e),
251        }
252    }
253    Err(TraceKeyError::Io(std::io::Error::new(
254        std::io::ErrorKind::TimedOut,
255        "trace digest key creation did not converge",
256    )))
257}
258
259/// Open an existing file for reading without following a symlink at the
260/// leaf. On Unix, `O_NOFOLLOW | O_NONBLOCK`: the former refuses a planted
261/// symlink, the latter guarantees the open itself can never block on a FIFO
262/// or device (regular-file reads are unaffected by `O_NONBLOCK`); the fd is
263/// then `fstat`-verified by the caller before any read.
264fn open_nofollow_read(path: &Path) -> Result<std::fs::File, TraceKeyError> {
265    #[cfg(unix)]
266    {
267        use std::os::unix::fs::OpenOptionsExt as _;
268        std::fs::OpenOptions::new()
269            .read(true)
270            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
271            .open(path)
272            .map_err(|e| {
273                if e.raw_os_error() == Some(libc::ELOOP) {
274                    TraceKeyError::SymlinkRefused(path.to_path_buf())
275                } else {
276                    TraceKeyError::Io(e)
277                }
278            })
279    }
280    #[cfg(not(unix))]
281    {
282        Ok(std::fs::OpenOptions::new().read(true).open(path)?)
283    }
284}
285
286/// Create a brand-new private (`0600`) file; fails if the path exists.
287fn open_create_new_private(path: &Path) -> Result<std::fs::File, TraceKeyError> {
288    #[cfg(unix)]
289    {
290        use std::os::unix::fs::OpenOptionsExt as _;
291        Ok(std::fs::OpenOptions::new()
292            .write(true)
293            .create_new(true)
294            .mode(agent_core::core::private_fs::FILE_MODE)
295            .custom_flags(libc::O_NOFOLLOW)
296            .open(path)?)
297    }
298    #[cfg(not(unix))]
299    {
300        Ok(std::fs::OpenOptions::new()
301            .write(true)
302            .create_new(true)
303            .open(path)?)
304    }
305}
306
307/// Keyed HMAC-SHA256 component digest with domain separation. The preimage
308/// (`bytes`) is never logged or stored; only the hex MAC output escapes.
309pub fn keyed_digest(key: &TraceDigestKey, domain: DigestDomain, bytes: &[u8]) -> ComponentDigest {
310    use hmac::Mac as _;
311    let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(key.0.as_ref())
312        .expect("HMAC-SHA256 accepts any key length");
313    mac.update(domain.label());
314    mac.update(bytes);
315    let out = mac.finalize().into_bytes();
316    let mut hex = String::with_capacity(64);
317    for b in out {
318        use std::fmt::Write as _;
319        let _ = write!(hex, "{b:02x}");
320    }
321    ComponentDigest(hex)
322}