envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! Linux TPM 2.0 backend.
//!
//! Uses the `tpm2-tools` CLI (`tpm2_createprimary`, `tpm2_evictcontrol`,
//! `tpm2_create`, `tpm2_load`, `tpm2_unseal`) so we avoid linking
//! `libtss2-esys` at compile time. Distros without a TPM or without
//! `tpm2-tools` installed degrade to the [`super::Backend::None`] path
//! transparently — `try_new` returns `None`.
//!
//! # Persistent primary
//!
//! We create a primary key under the owner hierarchy and persist it at
//! handle `0x81010001` on first use. Subsequent runs find it already
//! there. This avoids regenerating the primary every unlock and keeps
//! sealed blobs portable across `envseal` invocations on the same
//! machine.
//!
//! # Sealed envelope
//!
//! ```text
//! [4 bytes: u32 LE — length of pub blob (P)]
//! [4 bytes: u32 LE — length of priv blob (R)]
//! [P bytes: tpm2_create output -u (TPM2B_PUBLIC)]
//! [R bytes: tpm2_create output -r (TPM2B_PRIVATE)]
//! ```
//!
//! Plaintext only ever travels through `tpm2_create`'s stdin and
//! `tpm2_unseal`'s stdout — never to disk.

#![cfg(target_os = "linux")]

use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use crate::error::Error;

/// Persistent NV handle that holds our primary key. Inside the user
/// range (0x81010000..0x817fffff) so it doesn't conflict with platform
/// or owner-policy handles.
const PRIMARY_HANDLE: &str = "0x81010001";

/// RAII guard that removes a fixed list of paths on drop. Used by
/// `seal` and `unseal` to clean up the transient `.pub` / `.priv` /
/// `.ctx` files that `tpm2-tools` requires on disk. We define this
/// at module scope so clippy's `items_after_statements` lint doesn't
/// fire when we use it inside method bodies.
struct PathCleanup<'a> {
    paths: &'a [&'a Path],
}

impl Drop for PathCleanup<'_> {
    fn drop(&mut self) {
        for p in self.paths {
            let _ = std::fs::remove_file(p);
        }
    }
}

/// Handle for the Linux TPM 2.0 backend. Holds the path to a
/// short-lived scratch directory under `$XDG_RUNTIME_DIR` (tmpfs,
/// owner-only) where `tpm2_create` / `tpm2_load` ephemeral
/// `.pub` / `.priv` / `.ctx` files live for the duration of one
/// seal/unseal call.
pub struct Tpm2Keystore {
    /// Resolved temp directory we own for transient `.pub` / `.priv` files.
    /// Created lazily; one per process.
    workdir: PathBuf,
}

impl Tpm2Keystore {
    /// Probe for TPM2 availability:
    /// 1. `/dev/tpmrm0` (resource-manager) or `/dev/tpm0` exists, AND
    /// 2. `tpm2_getcap` is on PATH and succeeds, AND
    /// 3. our persistent primary handle is reachable (or createable).
    pub fn try_new() -> Option<Self> {
        let device_present = Path::new("/dev/tpmrm0").exists() || Path::new("/dev/tpm0").exists();
        if !device_present {
            return None;
        }

        if !tool_available("tpm2_getcap") {
            return None;
        }
        if !tool_available("tpm2_createprimary") {
            return None;
        }
        if !tool_available("tpm2_create") {
            return None;
        }
        if !tool_available("tpm2_load") {
            return None;
        }
        if !tool_available("tpm2_unseal") {
            return None;
        }
        if !tool_available("tpm2_evictcontrol") {
            return None;
        }

        // Quick health check: tpm2_getcap properties-fixed must succeed.
        let ok = Command::new("tpm2_getcap")
            .arg("properties-fixed")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .is_ok_and(|s| s.success());
        if !ok {
            return None;
        }

        // Build our scratch dir under XDG_RUNTIME_DIR (tmpfs, owner-only)
        // or fall back to /tmp/envseal-tpm-<uid>.
        let workdir = scratch_dir();
        if std::fs::create_dir_all(&workdir).is_err() {
            return None;
        }
        // Owner-only perms
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&workdir, std::fs::Permissions::from_mode(0o700));
        }

        // Ensure the primary exists (idempotent).
        if ensure_primary(&workdir).is_err() {
            return None;
        }

        Some(Self { workdir })
    }

    /// Wrap `plaintext` as a TPM2 sealed object under our persistent
    /// primary handle. Plaintext flows over `tpm2_create`'s stdin —
    /// it never lands on disk in the clear. The returned blob carries
    /// both `TPM2B_PUBLIC` and `TPM2B_PRIVATE` halves, which together
    /// allow `tpm2_load` to materialize the sealed object on this
    /// same TPM later.
    pub fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
        let pub_path = self
            .workdir
            .join(format!("seal-{}.pub", std::process::id()));
        let priv_path = self
            .workdir
            .join(format!("seal-{}.priv", std::process::id()));
        let cleanup_paths = [pub_path.as_path(), priv_path.as_path()];
        let _cleanup = PathCleanup {
            paths: &cleanup_paths,
        };

        let mut child = Command::new("tpm2_create")
            .arg("-C")
            .arg(PRIMARY_HANDLE)
            .arg("-i")
            .arg("-") // read sealed-data from stdin
            .arg("-u")
            .arg(&pub_path)
            .arg("-r")
            .arg(&priv_path)
            .stdin(Stdio::piped())
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .spawn()
            .map_err(|e| Error::CryptoFailure(format!("tpm2_create spawn failed: {e}")))?;

        if let Some(stdin) = child.stdin.as_mut() {
            stdin.write_all(plaintext).map_err(|e| {
                Error::CryptoFailure(format!("writing to tpm2_create stdin failed: {e}"))
            })?;
        }
        let output = child
            .wait_with_output()
            .map_err(|e| Error::CryptoFailure(format!("tpm2_create wait failed: {e}")))?;
        if !output.status.success() {
            return Err(Error::CryptoFailure(format!(
                "tpm2_create failed: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            )));
        }

        let pub_bytes = std::fs::read(&pub_path)
            .map_err(|e| Error::CryptoFailure(format!("reading sealed pub failed: {e}")))?;
        let priv_bytes = std::fs::read(&priv_path)
            .map_err(|e| Error::CryptoFailure(format!("reading sealed priv failed: {e}")))?;

        Ok(pack_envelope(&pub_bytes, &priv_bytes))
    }

    /// Unwrap a previously-sealed blob through `tpm2_load` followed
    /// by `tpm2_unseal`. Plaintext returns over the unseal stdout —
    /// again, never landing on disk. Fails if the TPM has been
    /// cleared, the persistent primary handle is no longer there,
    /// or the blob originated from a different TPM device.
    pub fn unseal(&self, sealed: &[u8]) -> Result<Vec<u8>, Error> {
        let (pub_bytes, priv_bytes) = unpack_envelope(sealed)?;

        let pub_path = self
            .workdir
            .join(format!("unseal-{}.pub", std::process::id()));
        let priv_path = self
            .workdir
            .join(format!("unseal-{}.priv", std::process::id()));
        let ctx_path = self
            .workdir
            .join(format!("unseal-{}.ctx", std::process::id()));
        let cleanup_paths = [pub_path.as_path(), priv_path.as_path(), ctx_path.as_path()];
        let _cleanup = PathCleanup {
            paths: &cleanup_paths,
        };

        write_owner_only(&pub_path, pub_bytes)?;
        write_owner_only(&priv_path, priv_bytes)?;

        // Load the sealed object into a transient handle.
        let load = Command::new("tpm2_load")
            .arg("-C")
            .arg(PRIMARY_HANDLE)
            .arg("-u")
            .arg(&pub_path)
            .arg("-r")
            .arg(&priv_path)
            .arg("-c")
            .arg(&ctx_path)
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .output()
            .map_err(|e| Error::CryptoFailure(format!("tpm2_load spawn failed: {e}")))?;
        if !load.status.success() {
            return Err(Error::CryptoFailure(format!(
                "tpm2_load failed (different machine, wiped TPM, or corrupted blob): {}",
                String::from_utf8_lossy(&load.stderr).trim()
            )));
        }

        // Unseal — plaintext on stdout.
        let unseal = Command::new("tpm2_unseal")
            .arg("-c")
            .arg(&ctx_path)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()
            .map_err(|e| Error::CryptoFailure(format!("tpm2_unseal spawn failed: {e}")))?;
        if !unseal.status.success() {
            return Err(Error::CryptoFailure(format!(
                "tpm2_unseal failed: {}",
                String::from_utf8_lossy(&unseal.stderr).trim()
            )));
        }
        Ok(unseal.stdout)
    }
}

fn ensure_primary(workdir: &Path) -> Result<(), Error> {
    // If the persistent handle already resolves, we're done.
    let probe = Command::new("tpm2_readpublic")
        .arg("-c")
        .arg(PRIMARY_HANDLE)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
    if matches!(probe, Ok(s) if s.success()) {
        return Ok(());
    }

    // Otherwise create a primary key in the owner hierarchy and
    // persist it. Algorithm: ECC NIST-P256, restricted decryption key
    // (the SRK template).
    let primary_ctx = workdir.join("primary.ctx");
    let create = Command::new("tpm2_createprimary")
        .arg("-C")
        .arg("o") // owner hierarchy
        .arg("-G")
        .arg("ecc") // ECC primary
        .arg("-c")
        .arg(&primary_ctx)
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output()
        .map_err(|e| Error::CryptoFailure(format!("tpm2_createprimary spawn failed: {e}")))?;
    if !create.status.success() {
        return Err(Error::CryptoFailure(format!(
            "tpm2_createprimary failed: {}",
            String::from_utf8_lossy(&create.stderr).trim()
        )));
    }

    let evict = Command::new("tpm2_evictcontrol")
        .arg("-C")
        .arg("o")
        .arg("-c")
        .arg(&primary_ctx)
        .arg(PRIMARY_HANDLE)
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output()
        .map_err(|e| Error::CryptoFailure(format!("tpm2_evictcontrol spawn failed: {e}")))?;
    let _ = std::fs::remove_file(&primary_ctx);
    if !evict.status.success() {
        return Err(Error::CryptoFailure(format!(
            "tpm2_evictcontrol failed: {}",
            String::from_utf8_lossy(&evict.stderr).trim()
        )));
    }
    Ok(())
}

fn tool_available(name: &str) -> bool {
    Command::new(name)
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
}

fn scratch_dir() -> PathBuf {
    if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
        return PathBuf::from(rt).join("envseal-tpm");
    }
    let uid = unsafe { libc::geteuid() };
    // Use a random suffix so the path is not predictable and
    // multiple threads/processes don't collide.
    let mut rng = rand::thread_rng();
    let rnd: u64 = rand::Rng::gen(&mut rng);
    PathBuf::from(format!("/tmp/envseal-tpm-{uid}-{rnd:016x}"))
}

fn write_owner_only(path: &Path, contents: &[u8]) -> Result<(), Error> {
    use std::os::unix::fs::OpenOptionsExt;
    let display = path.display();
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .mode(0o600)
        .open(path)
        .map_err(|e| Error::CryptoFailure(format!("opening {display} failed: {e}")))?;
    f.write_all(contents)
        .map_err(|e| Error::CryptoFailure(format!("writing {display} failed: {e}")))?;
    f.sync_all()
        .map_err(|e| Error::CryptoFailure(format!("syncing {display} failed: {e}")))?;
    Ok(())
}

fn pack_envelope(pub_bytes: &[u8], priv_bytes: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(8 + pub_bytes.len() + priv_bytes.len());
    // The TPM sealed-object halves are kilobytes, never approaching
    // 4 GiB. `try_from(...).unwrap_or(u32::MAX)` is defensive: a
    // pathological caller passing a 4 GiB+ slice gets a length-mismatch
    // error from `unpack_envelope` rather than a panic.
    let p_len = u32::try_from(pub_bytes.len()).unwrap_or(u32::MAX);
    let r_len = u32::try_from(priv_bytes.len()).unwrap_or(u32::MAX);
    out.extend_from_slice(&p_len.to_le_bytes());
    out.extend_from_slice(&r_len.to_le_bytes());
    out.extend_from_slice(pub_bytes);
    out.extend_from_slice(priv_bytes);
    out
}

fn unpack_envelope(sealed: &[u8]) -> Result<(&[u8], &[u8]), Error> {
    // Pattern-match the header to get fixed-size arrays without
    // any `unwrap()` on `try_into()`.
    let (p_len_bytes, rest) = sealed.split_first_chunk::<4>().ok_or_else(|| {
        Error::CryptoFailure("TPM2 envelope shorter than length header".to_string())
    })?;
    let (r_len_bytes, body) = rest.split_first_chunk::<4>().ok_or_else(|| {
        Error::CryptoFailure("TPM2 envelope shorter than length header".to_string())
    })?;
    let p_len = u32::from_le_bytes(*p_len_bytes) as usize;
    let r_len = u32::from_le_bytes(*r_len_bytes) as usize;
    if body.len() != p_len + r_len {
        return Err(Error::CryptoFailure(format!(
            "TPM2 envelope length mismatch: header says {p_len}+{r_len}, body has {}",
            body.len()
        )));
    }
    let (pub_bytes, priv_bytes) = body.split_at(p_len);
    Ok((pub_bytes, priv_bytes))
}

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

    #[test]
    fn envelope_roundtrip() {
        let pub_b = b"PUB-DATA-HERE";
        let priv_b = b"PRIV-DATA-HERE-LONGER";
        let env = pack_envelope(pub_b, priv_b);
        let (p, r) = unpack_envelope(&env).unwrap();
        assert_eq!(p, pub_b);
        assert_eq!(r, priv_b);
    }

    #[test]
    fn envelope_rejects_truncated() {
        assert!(unpack_envelope(&[0u8; 4]).is_err());
    }

    #[test]
    fn envelope_rejects_length_mismatch() {
        let mut env = pack_envelope(b"AAA", b"BB");
        env.pop(); // drop one byte
        assert!(unpack_envelope(&env).is_err());
    }

    /// Roundtrip on actual TPM hardware. Skipped on CI runners that
    /// don't have a TPM exposed.
    #[test]
    fn seal_then_unseal_roundtrips_when_tpm_present() {
        let Some(ks) = Tpm2Keystore::try_new() else {
            eprintln!("TPM2 not available on this host — skipping");
            return;
        };
        let plaintext = b"the master key wrapped envelope";
        let sealed = ks.seal(plaintext).expect("TPM2 seal must succeed");
        assert_ne!(sealed.as_slice(), plaintext);
        let recovered = ks.unseal(&sealed).expect("TPM2 unseal must succeed");
        assert_eq!(recovered, plaintext);
    }
}