Fix review findings: PAX sizes, agent stdin, PEM parity, sticky writer

- absorb uses the PAX-corrected entry size (large Go/bsdtar archives were
  stored truncated) and refuses GNU sparse entries
- the agent reads the key from fd 0 straight into locked memory, and never
  unlinks a live agent's socket
- the key file PEM parser is a port of Go's pem.Decode
- the STREAM writer fails permanently after a write error; plaintext is
  cleared on reader error paths; AEAD, HMAC and hash states are zeroized
- add/migrate refuse paths inside the vault; publish reports filesystems
  without an atomic no-replace primitive clearly and treats a leftover
  temporary name as a warning; Windows rejects NTFS-special names
- the leak test runs in its own process

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andy Dixon
2026-10-08 12:23:27 +01:00
co-authored by Claude
parent 25f50ed73b
commit 91d49aeecf
12 changed files with 358 additions and 69 deletions
+111 -37
View File
@@ -124,49 +124,102 @@ pub fn encode_pem(body: &[u8]) -> String {
}
/// The block body if `data` is one `VAULT KEY` PEM block, optionally with
/// text before it (as Go's `pem.Decode` allows) and only whitespace after.
/// text before it and only whitespace after: a port of Go's `pem.Decode`
/// followed by vault's checks on its result.
pub fn decode_pem(data: &[u8]) -> Option<Vec<u8>> {
let begin = format!("-----BEGIN {PEM_TYPE}-----");
let end = format!("-----END {PEM_TYPE}-----");
let start = if data.starts_with(b"-----BEGIN ") {
0
} else {
find(data, b"\n-----BEGIN ")? + 1
};
let mut lines = data[start..].split_inclusive(|&c| c == b'\n');
let first = lines.next()?;
if trim_right(first) != begin.as_bytes() {
let (typ, body, rest) = go_pem_decode(data)?;
if typ != PEM_TYPE.as_bytes() || !go_trim_space(rest).is_empty() {
return None;
}
let mut b64 = Vec::new();
let mut after = None;
let mut consumed = start + first.len();
for line in lines.by_ref() {
consumed += line.len();
let t = trim_right(line);
if t == end.as_bytes() {
after = Some(consumed);
break;
}
if t.contains(&b':') {
// A PEM header line; Go accepts and ignores them.
continue;
}
b64.extend(t.iter().filter(|c| !matches!(c, b' ' | b'\t')));
}
let after = after?;
if !data[after..].iter().all(|c| c.is_ascii_whitespace()) {
return None;
}
base64::engine::general_purpose::STANDARD.decode(&b64).ok()
Some(body)
}
fn trim_right(l: &[u8]) -> &[u8] {
let mut e = l.len();
while e > 0 && matches!(l[e - 1], b' ' | b'\t' | b'\r' | b'\n') {
e -= 1;
/// Go's `getLine`: up to the next LF (dropping one CR before it), with
/// trailing spaces and tabs removed, and the rest after the LF.
fn get_line(data: &[u8]) -> (&[u8], &[u8]) {
let (mut i, j) = match data.iter().position(|&c| c == b'\n') {
Some(i) => (i, i + 1),
None => (data.len(), data.len()),
};
if j > i && i > 0 && data[i - 1] == b'\r' {
i -= 1;
}
let mut line = &data[..i];
while let [rest @ .., b' ' | b'\t'] = line {
line = rest;
}
(line, &data[j..])
}
/// Go's `bytes.TrimSpace` (Unicode white space in UTF-8 text).
fn go_trim_space(b: &[u8]) -> &[u8] {
match std::str::from_utf8(b) {
Ok(s) => s.trim_matches(char::is_whitespace).as_bytes(),
Err(_) => b.trim_ascii(),
}
}
/// Go's `encoding/pem.Decode`: the first well-formed block's type, bytes
/// and the data after it. Malformed candidates are skipped, as in Go.
fn go_pem_decode(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>, &[u8])> {
const START: &[u8] = b"\n-----BEGIN ";
const END: &[u8] = b"\n-----END ";
const EOL: &[u8] = b"-----";
let mut rest = data;
loop {
if rest.starts_with(&START[1..]) {
rest = &rest[START.len() - 1..];
} else if let Some(i) = find(rest, START) {
rest = &rest[i + START.len()..];
} else {
return None;
}
let (type_line, r) = get_line(rest);
rest = r;
let Some(typ) = type_line.strip_suffix(EOL) else { continue };
let mut headers = 0;
loop {
if rest.is_empty() {
return None;
}
let (line, next) = get_line(rest);
if !line.contains(&b':') {
break;
}
headers += 1;
rest = next;
}
let (end_index, trailer_index) = if headers == 0 && rest.starts_with(&END[1..]) {
(0, END.len() - 1)
} else {
match find(rest, END) {
Some(i) => (i, i + END.len()),
None => continue,
}
};
let trailer = &rest[trailer_index..];
let tlen = typ.len() + EOL.len();
if trailer.len() < tlen {
continue;
}
let (t, rest_of_end) = trailer.split_at(tlen);
if !t.starts_with(typ) || !t.ends_with(EOL) || !get_line(rest_of_end).0.is_empty() {
continue;
}
// Go's StdEncoding skips CR and LF; removeSpacesAndTabs drops the rest.
let b64: Vec<u8> = rest[..end_index]
.iter()
.copied()
.filter(|c| !matches!(c, b' ' | b'\t' | b'\r' | b'\n'))
.collect();
let lenient = base64::engine::GeneralPurpose::new(
&base64::alphabet::STANDARD,
base64::engine::GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
let Ok(bytes) = lenient.decode(&b64) else { continue };
let after = get_line(&rest[end_index + END.len() - 1..]).1;
return Some((typ.to_vec(), bytes, after));
}
&l[..e]
}
fn find(h: &[u8], n: &[u8]) -> Option<usize> {
@@ -327,6 +380,27 @@ mod tests {
assert!(decode_pem(format!("{pem}x").as_bytes()).is_none());
assert!(decode_pem(pem.replace("VAULT KEY", "VAULT KEX").as_bytes()).is_none());
assert!(decode_pem(pem.replace("-----END", "-----FIN").as_bytes()).is_none());
// Cases where an earlier, simpler parser differed from Go's pem.Decode.
// (In the second body line: a colon in the first makes it a header.)
let first = pem.find('\n').unwrap();
let mid = first + 1 + pem[first + 1..].find('\n').unwrap() + 30;
assert!(
decode_pem(format!("{}:{}", &pem[..mid], &pem[mid..]).as_bytes()).is_none(),
"colon inside the body"
);
assert!(
decode_pem(format!("{pem}\u{b}\u{a0}").as_bytes()).is_some(),
"Unicode space after the block"
);
assert!(decode_pem(pem.replacen("\n", "\r\n", 3).as_bytes()).is_some(), "CRLF lines");
assert!(
decode_pem(format!("-----BEGIN X\n{pem}").as_bytes()).is_some(),
"a bad BEGIN line before the block"
);
assert!(
decode_pem(format!("-----BEGIN VAULT KEY-----\nProc-Type: 4,x\n\n{}", &pem[26..]).as_bytes()).is_some(),
"headers"
);
let mut short = body.clone();
short.pop();
assert_eq!(parse_body(&short).err(), Some(Error::NotAKeyFile));