Cross builds, packaging split, CI, fuzzing, supply-chain policy

build.sh builds the Rust targets with the Go-era names, refuses an OUT
that is /, $HOME or the tree, checks static linking and names the Go
targets Rust cannot build. packaging/package.sh builds and inspects
packages without signing or publishing; repo.sh reuses it. Seven fuzz
targets (one found a URL-parser panic, fixed here with a regression
test), cargo-deny policy, CI for lint, tests on Linux/macOS/Windows,
cross builds with qemu runs, supply chain and fuzzing. Compatibility
specification and security notes.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andy Dixon
2026-10-08 11:44:23 +01:00
co-authored by Claude
parent a98931779a
commit cddde5ca52
27 changed files with 2347 additions and 114 deletions
+102
View File
@@ -0,0 +1,102 @@
name: ci
on:
push:
pull_request:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Oracle-backed interoperability tests must run, not skip, in CI.
VAULT_REQUIRE_ORACLE: "1"
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- run: rustup show active-toolchain || rustup toolchain install
- run: cargo fmt --check
- run: cargo clippy --locked --all-targets --all-features -- -D warnings
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, macos-15-intel, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
# The Go toolchain builds the test-only oracle; vault itself needs no Go.
- uses: actions/setup-go@v6
with:
go-version: "1.26.x"
cache-dependency-path: oracle/go/go.sum
- run: rustup show active-toolchain || rustup toolchain install
- name: test
shell: bash
run: |
if [[ $RUNNER_OS == Windows ]]; then unset VAULT_REQUIRE_ORACLE; fi
cargo test --locked --all-targets --all-features
- run: cargo test --locked --doc
- run: cargo build --locked --release
- name: oracle self-test
if: runner.os == 'Linux'
working-directory: oracle/go
run: go test ./...
cross:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- run: rustup show active-toolchain || rustup toolchain install
- name: zig and cargo-zigbuild
run: |
curl -sSLo zig.tar.xz https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz
echo "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 zig.tar.xz" | sha256sum -c
mkdir -p "$HOME/zig" && tar -xJf zig.tar.xz -C "$HOME/zig" --strip-components=1
echo "$HOME/zig" >>"$GITHUB_PATH"
cargo install cargo-zigbuild --version 0.23.4 --locked
- name: targets
run: |
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl i686-unknown-linux-musl \
arm-unknown-linux-musleabihf armv7-unknown-linux-musleabihf riscv64gc-unknown-linux-musl \
loongarch64-unknown-linux-musl powerpc64le-unknown-linux-musl powerpc64-unknown-linux-gnu \
s390x-unknown-linux-gnu x86_64-pc-windows-gnu i686-pc-windows-gnu aarch64-pc-windows-gnullvm wasm32-wasip1
- name: build
run: |
TARGETS="linux/amd64 linux/arm64 linux/386 linux/armv6 linux/armv7 linux/riscv64 linux/loong64 \
linux/ppc64le linux/ppc64 linux/s390x windows/amd64 windows/386 windows/arm64 wasip1/wasm" ./build.sh
- name: run the arm64 and big-endian s390x builds under qemu
run: |
sudo apt-get update -q && sudo apt-get install -yq qemu-user
cargo build --locked --release
EMU=qemu-aarch64 scripts/smoke.sh dist/vault-linux-arm64 target/release/vault
EMU="qemu-s390x -L /usr/s390x-linux-gnu" scripts/smoke.sh dist/vault-linux-s390x target/release/vault || echo "s390x needs a glibc sysroot; build-only"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
supply-chain:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- run: rustup show active-toolchain || rustup toolchain install
- run: cargo install cargo-deny --version 0.20.2 --locked
- run: cargo deny --locked check
fuzz:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- run: rustup toolchain install nightly-2026-10-01 --profile minimal
- run: cargo +nightly-2026-10-01 install cargo-fuzz --version 0.13.2 --locked
- name: a short run of every target
working-directory: fuzz
run: |
for t in $(cargo +nightly-2026-10-01 fuzz list); do
cargo +nightly-2026-10-01 fuzz run "$t" -- -max_total_time=60
done
+4
View File
@@ -7,3 +7,7 @@
/docs/guide/vault-guide.pdf
/docs/guide/*.log
/target
fuzz/target
fuzz/corpus
fuzz/artifacts
fuzz/coverage
+5
View File
@@ -35,3 +35,8 @@ rustix = { version = "1.1.5", features = ["fs", "process", "mm", "termios", "eve
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_Security"] }
[profile.release]
codegen-units = 1
lto = true
strip = true
+146
View File
@@ -0,0 +1,146 @@
# Security notes
What vault protects, how, and where the protection stops. See
`docs/COMPATIBILITY.md` for the exact formats.
## What is protected
- **Contents, names, sizes, modes and times** of stored files, against
anyone who reads the vault directory without the passphrase: everything is
inside Fury files sealed to a hybrid FrodoKEM-640 + ML-KEM-768 key, so the
file key stays secret unless both KEMs are broken.
- **Structure**: records are one fixed size; objects are at most 4 MiB and
padded to power-of-two buckets; object names derive from a secret per-file
seed, so nothing on disk links a file's objects; every ciphertext and shard
directory has its timestamps pinned to the epoch; all 256 shards exist from
the start; `init` adds filler sealed to a discarded key.
- **Integrity**: every byte that is acted on is authenticated (header MAC,
STREAM tags with a final-chunk flag, no trailing data); an object starts
with its own ID, so objects cannot be swapped, reordered or replayed under
another record; `get` publishes output only after the whole file, padding
and end of file included, has authenticated.
- **Deniability**: the key file always has two equal slots, one possibly
random; both are always tried; decoy and real files are indistinguishable
from filler to the other passphrase.
## What is not protected
- The number and total size of ciphertexts, and when the directory changes,
are visible to whoever stores it.
- A passphrase is only as strong as it is: Argon2id (3 passes, 64 MiB, 4
lanes) slows guessing, it does not prevent it.
- Anyone who can run code as you while the vault is unlocked (or watch you
type the passphrase) can read everything.
- Nothing here hides that you use vault.
## Secrets in memory
Passphrases, seeds, private keys, key-encryption keys and plaintext buffers
are held in owned types (`SecretArray`, `SecretVec`, `LockedBuf`) that are
not `Clone`, `Copy`, `Debug` or serialisable, have fixed allocations that
never reallocate, and are cleared with `zeroize` when dropped — on success,
on error and while unwinding from a panic (release builds unwind).
What cannot be cleared, so is not claimed:
- copies the compiler leaves in registers or on the stack;
- copies inside third-party key objects: `chacha20poly1305` cipher states
(the key schedule), `ml-kem`'s `Seed` passed by value (the decapsulation
key itself is zeroized on drop with the crate's `zeroize` feature),
`hmac`/`hkdf` internal states, `argon2` internals other than its working
memory (which vault allocates and clears itself);
- the passphrase in `$VAULT_PASSPHRASE` (it is in the process environment
for the process lifetime) and in the terminal driver's buffers;
- anything after `abort`, `kill -9`, a power cut, or once swapped out;
- file data in the OS page cache, and plaintext temporary files on disk
(below).
None of this is erasure from physical memory.
## The agent
`vault unlock` re-executes the binary as `vault __agent` with
`VAULT_PASSPHRASE` and `VAULT_DECOY_PASSPHRASE` removed from its environment,
no secret in its arguments, its working directory `/` and standard error
closed. It hands over the 34-byte suite ID and seed through a pipe. Before
reading it, the agent:
1. sets the core-dump size limit to zero;
2. on Linux, sets `PR_SET_DUMPABLE` to 0 (no core dumps, no same-user
`ptrace`, `/proc/PID/mem` or `/proc/PID/environ`) and checks it took;
3. maps a private page, `mlock`s it and, on Linux, marks it
`MADV_DONTDUMP`, all checked.
Any failure is reported to `unlock` and the agent exits without the key;
there is no fallback to unprotected memory. On other Unix systems (macOS,
the BSDs, illumos) steps 1 and 3 apply; there is no portable equivalent of
`PR_SET_DUMPABLE`, so another process of the same user may be able to read
the agent's memory with a debugger.
The socket lives in a directory that must be owned by you with mode exactly
0700 (refused otherwise); the socket is 0600, checked before readiness. Each
request has a two-second deadline; the expiry deadline is enforced with a
timed `poll`, so it does not depend on another client arriving, and a key
is never handed out after expiry. On exit the agent clears the page and
removes the socket only if it is still its own. `unlock` waits at most ten
seconds for the agent to report and kills it otherwise.
Windows: the agent is not supported (`unlock` says so); every command asks
for the passphrase.
## Filesystem safety
- Output is published with an atomic no-replace primitive
(`renameat2(RENAME_NOREPLACE)`, `renameatx_np(RENAME_EXCL)`, `link`+`unlink`,
or `MoveFileExW` without replace), so an existing file, symlink (dangling
or not) or one created concurrently is never replaced; only vault's own
temporary file is removed. There is no check-then-rename fallback.
- The temporary output file is mode 0600 until the content has fully
authenticated; only then are the recorded mode and times applied.
- `get -r` opens each directory below the chosen destination with
`O_NOFOLLOW|O_DIRECTORY` relative to its parent, so a symlink planted
inside the destination cannot redirect files elsewhere. On Windows, which
has no `openat`, each component is checked not to be a link or junction
just before use; a concurrent swap between check and use is not excluded.
- Writes follow crash-safe order with explicit `fsync` of files and
directories (Unix): objects before their record; a new record before the
removal of the one it replaces; a record's removal before its objects'.
A failure after a record is already in place never deletes its objects.
Windows has no directory fsync through the APIs used; NTFS journals
metadata.
- The vault lock uses the same POSIX `fcntl` locks as the Go version, so
mixed Go and Rust processes exclude each other. Only a read-only
filesystem lets a reader proceed without the lock.
## Plaintext on disk
- `vault add URL` and `vault mirror` stream non-HTML downloads to a private
(0600) temporary file in `$TMPDIR` before storing them, because the size
must be known first; it is removed on every normal path, including
errors. HTML and CSS are rewritten in memory. `vault absorb` stores tar
entries straight from the stream with no temporary file.
- `vault get` writes plaintext where you ask; `vault migrate` deletes the
originals with `unlink`, which is not secure deletion.
## Network
Downloads verify TLS certificates (rustls with the platform verifier; it is
never disabled), follow at most 10 redirects (http and https only; for
`mirror`, only to the same host), and are bounded by connect, response and
whole-request deadlines and a byte budget counted after decompression. See
`vault(1)` for the defaults and flags.
## Cryptographic implementations
FrodoKEM-640-SHAKE is a port of CIRCL v1.6.4's implementation (constant-time
in the same places: no secret-dependent branches or indices; the
re-encryption check selects the rejection key with a mask). It reproduces
CIRCL's NIST KAT digest. ML-KEM-768 (`ml-kem`), ChaCha20-Poly1305 and
XChaCha20-Poly1305 (`chacha20poly1305`), Argon2id (`argon2`), HKDF, HMAC,
SHA-2 and SHA-3 are RustCrypto crates; TLS is rustls with `ring`. A Rust
rewrite is not stronger cryptography: the algorithms and parameters are
unchanged.
## Reporting
Please report vulnerabilities privately to andy@dixon.cx.
+128 -33
View File
@@ -1,48 +1,143 @@
#!/usr/bin/env bash
# Build vault for every OS and CPU architecture Go can target without cgo.
# Build vault for the supported operating systems and CPU architectures.
#
# ./build.sh # everything, into ./dist
# ./build.sh # every target this host can build, into ./dist
# TARGETS="linux/amd64 darwin/arm64" ./build.sh
# OUT=/tmp/release ./build.sh
# OUT=/tmp/release JOBS=4 ./build.sh
#
# Binaries are static, stripped and reproducible (CGO_ENABLED=0, -trimpath).
# iOS and most Android targets are skipped (they need cgo); see below.
# 32-bit ARM Linux is built twice: armv6 (Raspberry Pi Zero/1) and armv7.
# SHA256SUMS is written next to the binaries.
# Binaries keep the Go-era names, vault-OS-ARCH[.exe], and are stripped
# release builds from the pinned toolchain (rust-toolchain.toml) and the
# locked dependencies (Cargo.lock), with build paths remapped so the output
# does not depend on where the tree lives. Linux binaries are static (musl)
# except s390x and ppc64, which link glibc dynamically; static linking is
# checked. SHA256SUMS is written next to the binaries.
#
# How each target is built, and what the host needs:
# zig cargo-zigbuild and zig on PATH (Linux, Windows GNU ABI)
# mac a Mac with Xcode (macOS)
# cross `cross` and Docker (FreeBSD, NetBSD, illumos, Solaris)
# cargo nothing extra (WASI)
# A requested target the host cannot build is reported, the others are still
# built, and the script exits non-zero. See docs/PLATFORMS.md for the Go
# targets with no Rust equivalent; asking for one is an error.
#
# JOBS builds several targets at once, each in its own target directory
# under target/build-sh (default 1: one at a time, cargo using every CPU).
set -euo pipefail
cd "$(dirname "$0")"
root=$PWD
table() {
# os/arch Rust target method
cat <<-'EOF'
linux/amd64 x86_64-unknown-linux-musl zig
linux/arm64 aarch64-unknown-linux-musl zig
linux/386 i686-unknown-linux-musl zig
linux/armv6 arm-unknown-linux-musleabihf zig
linux/armv7 armv7-unknown-linux-musleabihf zig
linux/riscv64 riscv64gc-unknown-linux-musl zig
linux/loong64 loongarch64-unknown-linux-musl zig
linux/ppc64le powerpc64le-unknown-linux-musl zig
linux/ppc64 powerpc64-unknown-linux-gnu zig
linux/s390x s390x-unknown-linux-gnu zig
android/arm64 aarch64-unknown-linux-musl zig
windows/amd64 x86_64-pc-windows-gnu zig
windows/386 i686-pc-windows-gnu zig
windows/arm64 aarch64-pc-windows-gnullvm zig
darwin/amd64 x86_64-apple-darwin mac
darwin/arm64 aarch64-apple-darwin mac
freebsd/amd64 x86_64-unknown-freebsd cross
freebsd/386 i686-unknown-freebsd cross
netbsd/amd64 x86_64-unknown-netbsd cross
illumos/amd64 x86_64-unknown-illumos cross
solaris/amd64 x86_64-pc-solaris cross
wasip1/wasm wasm32-wasip1 cargo
EOF
}
# Go targets that Rust cannot build with a stable toolchain (no prebuilt
# standard library, or no Rust target at all). See docs/PLATFORMS.md.
unsupported="aix/ppc64 dragonfly/amd64 freebsd/arm freebsd/arm64 freebsd/riscv64 js/wasm
linux/mips linux/mipsle linux/mips64 linux/mips64le netbsd/386 netbsd/arm netbsd/arm64
openbsd/386 openbsd/amd64 openbsd/arm openbsd/arm64 openbsd/ppc64 openbsd/riscv64
plan9/386 plan9/amd64 plan9/arm"
out=${OUT:-dist}
# iOS and 32-bit/x86 Android need cgo and a vendor SDK. android/arm64 builds
# a static binary that runs in Termux.
targets=${TARGETS:-$(go tool dist list | grep -vE '^ios/|^android/(386|amd64|arm)$')}
jobs=${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}
jobs=${JOBS:-1}
targets=${TARGETS:-$(table | awk '{print $1}')}
rm -rf "$out"
mkdir -p "$out"
# Never clean anything but our own output, and never in a directory that
# matters: OUT must be a dedicated directory, not /, $HOME or the tree.
mkdir -p -- "$out"
out=$(cd -- "$out" && pwd -P)
case $out in
/ | "$(cd ~ && pwd -P)" | "$root") echo "refusing OUT=$out: use a dedicated directory" >&2; exit 2 ;;
esac
rm -f -- "$out"/vault-* "$out"/SHA256SUMS "$out"/*.log
export CARGO_TARGET_DIR_BASE=$root/target/build-sh
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$root=vault --remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=cargo"
export OUT_DIR=$out JOBS_N=$jobs HOST_OS=$(uname -s)
build_one() {
local os=${1%/*} arch=${1#*/} variants=("") name ext=""
[[ $os == windows ]] && ext=.exe
[[ $arch == wasm ]] && ext=.wasm
[[ $os/$arch == linux/arm ]] && variants=(6 7)
for v in "${variants[@]}"; do
name=vault-$os-$arch${v:+v$v}$ext
if ! GOOS=$os GOARCH=$arch GOARM=${v:-} CGO_ENABLED=0 \
go build -trimpath -buildvcs=false -ldflags='-s -w -buildid=' -o "$OUT_DIR/$name" . 2>"$OUT_DIR/$name.log"; then
echo "FAILED $name" >&2
cat "$OUT_DIR/$name.log" >&2
return 1
local want=$1 row os triple method name ext="" tdir bin
row=$(table | awk -v t="$want" '$1 == t')
if [[ -z $row ]]; then
if grep -qw -- "$want" <<<"$unsupported"; then
echo "UNSUPPORTED $want: no stable Rust target; see docs/PLATFORMS.md" >&2
else
echo "UNKNOWN $want" >&2
fi
rm "$OUT_DIR/$name.log"
echo "built $name"
done
return 1
fi
read -r _ triple method <<<"$row"
os=${want%/*}
[[ $os == windows ]] && ext=.exe
[[ $os == wasip1 ]] && ext=.wasm
name=vault-${want/\//-}$ext
tdir=$root/target
[[ $JOBS_N != 1 ]] && tdir=$CARGO_TARGET_DIR_BASE/$triple
case $method in
zig)
command -v cargo-zigbuild >/dev/null && command -v zig >/dev/null ||
{ echo "SKIPPED $want: needs cargo-zigbuild and zig" >&2; return 1; }
cmd=(cargo zigbuild) ;;
mac)
[[ $HOST_OS == Darwin ]] || { echo "SKIPPED $want: needs a Mac with Xcode" >&2; return 1; }
cmd=(cargo build) ;;
cross)
command -v cross >/dev/null && command -v docker >/dev/null ||
{ echo "SKIPPED $want: needs cross and docker" >&2; return 1; }
cmd=(cross build) ;;
cargo) cmd=(cargo build) ;;
esac
local flags=$RUSTFLAGS
# zig's musl getrandom() never reaches the kernel on riscv64 (it spins);
# make the getrandom crate issue the system call itself.
[[ $triple == riscv64*-musl ]] && flags+=' --cfg getrandom_backend="linux_raw"'
if ! RUSTFLAGS=$flags CARGO_TARGET_DIR=$tdir "${cmd[@]}" --release --locked --target "$triple" >"$OUT_DIR/$name.log" 2>&1; then
echo "FAILED $want ($triple)" >&2
tail -20 "$OUT_DIR/$name.log" >&2
return 1
fi
bin=$tdir/$triple/release/vault$ext
[[ $os == wasip1 ]] && bin=$tdir/$triple/release/vault.wasm
cp -- "$bin" "$OUT_DIR/$name"
rm -f -- "$OUT_DIR/$name.log"
if [[ $triple == *-musl* ]] && command -v file >/dev/null; then
file -L "$OUT_DIR/$name" | grep -q 'static' || { echo "FAILED $want: not statically linked" >&2; return 1; }
fi
echo "built $name ($triple)"
}
export -f build_one
export OUT_DIR=$out
export -f build_one table
export unsupported
printf '%s\n' $targets | xargs -P "$jobs" -I{} bash -c 'build_one {}'
status=0
printf '%s\n' $targets | xargs -P "$jobs" -I{} bash -c 'build_one {}' || status=1
cd "$out"
if command -v sha256sum >/dev/null; then sha256sum vault-* >SHA256SUMS; else shasum -a 256 vault-* >SHA256SUMS; fi
echo "$(ls vault-* | wc -l | tr -d ' ') binaries in $out"
cd -- "$out"
if ls vault-* >/dev/null 2>&1; then
if command -v sha256sum >/dev/null; then sha256sum vault-* >SHA256SUMS; else shasum -a 256 vault-* >SHA256SUMS; fi
fi
echo "$(ls vault-* 2>/dev/null | wc -l | tr -d ' ') binaries in $out"
exit $status
+33
View File
@@ -0,0 +1,33 @@
# cargo-deny policy: run `cargo deny --locked check`.
[graph]
all-features = true
[advisories]
# Every advisory is an error unless listed here with a reason.
ignore = []
[licenses]
# Licences compatible with distributing vault under GPL-3.0-or-later.
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-3-Clause",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"ISC",
"MIT",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
confidence-threshold = 0.9
[bans]
multiple-versions = "warn"
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+187
View File
@@ -0,0 +1,187 @@
# Compatibility specification
This is the on-disk and on-wire contract between the Go implementation
(frozen in `oracle/go`, commit `4c1507c`) and the Rust implementation. It is
derived from the Go sources and pinned by vectors the Go oracle generated
(`tests/vectors`, regenerated with `oracle/gen-vectors.sh`). The Rust port is
an implementation migration, not a format migration: nothing here changes,
no data is rekeyed or converted, and no new suite becomes the default.
Each section names the tests that hold the Rust code to it. "Go" below always
means the oracle.
## 1. Key file
| Item | Value |
| --- | --- |
| Encoding | one PEM block, type `VAULT KEY`, standard base64 with padding, 64 columns, as Go's `pem.Encode` |
| Body | exactly 198 bytes: an 18-byte header and two 90-byte slots |
| Header | `VAULTKEY1`, Argon2id time `3` (u32 BE), memory `65536` KiB (u32 BE), lanes `4` (u8) |
| Slot | 16-byte salt, 24-byte XChaCha20-Poly1305 nonce, 34 bytes sealed (u16 BE suite ID, 32-byte master seed), 16-byte tag |
| Slot key | Argon2id v1.3 (passphrase bytes as given, no normalisation, salt, t=3, m=64 MiB, p=4, 32 bytes) |
| Associated data | the 18-byte header |
| Second slot | a decoy identity's slot, or 90 random bytes; which slot is real is random |
| Unlock | Argon2id runs for both slots every time (Rust runs them concurrently); the first slot that opens wins; "incorrect passphrase" if neither |
Accepted framing: optional text before the block (Go's `pem.Decode`), only
whitespace after it, header lines inside the block ignored. Files over 64 KiB
are refused outright (Go would read them, then reject them).
Tests: `keyfile::tests::*` (Argon2id vectors, Go-written key files with and
without decoy open to the Go-recorded seeds, byte-identical PEM re-encoding,
malformed framing, NFC/NFD passphrases differ).
## 2. Hybrid suite `0x0001`, tag `frodo640shake+mlkem768`
- Master seed: 32 bytes. Expansion:
`HKDF-SHA-256(ikm = seed, salt = none, info = "fury/v1 seed-expand frodo640shake+mlkem768")`,
read as 48 bytes of FrodoKEM seed then 64 bytes of ML-KEM seed.
- FrodoKEM-640-SHAKE exactly as CIRCL v1.6.4 `kem/frodo/frodo640shake`
(the round-3 FrodoKEM, not the ISO/eFrodoKEM variants): key seed
`s(16) || seedSE(16) || z(16)`; public key 9616 bytes (`seedA || pack(B)`);
private key 19888 bytes (`s || pk || S as u16 LE || H(pk)`); ciphertext 9720
bytes; 16-byte shared secret; 16-byte encapsulation seed `mu`; implicit
rejection returns `SHAKE128(ct || s)`.
- ML-KEM-768 (FIPS 203) with seed `d || z`; encapsulation key 1184 bytes,
expanded decapsulation key 2400 bytes, ciphertext 1088 bytes, 32-byte
shared secret, implicit rejection.
- Public key: FrodoKEM public key `||` ML-KEM encapsulation key (10800 bytes).
Private key (for comparison only): FrodoKEM private `||` ML-KEM expanded.
- Wrap: fresh, independent randomness for both encapsulations every time.
`KEK = HKDF-SHA-256(ikm = ss1 || ss2, salt = none, info = "fury/v1 kek frodo640shake+mlkem768" || ct1 || ct2)`;
the 32-byte file key is sealed with ChaCha20-Poly1305 under the KEK and an
all-zero 12-byte nonce. The zero nonce is safe only because each KEK is new
and used once; the Rust code keeps that sealing function private to the
suite.
- Stanza: type `frodo640shake+mlkem768`, two arguments
`base64(ct1)`, `base64(ct2)`, body = 48-byte sealed file key.
- Unwrap: a stanza of another type is "not mine"; wrong argument count,
bad base64 or wrong ciphertext lengths are fatal for the file; an AEAD
failure (wrong key) is "not mine". The first stanza that opens wins.
Rust implementation: FrodoKEM is a line-by-line port of CIRCL
(`src/crypto/frodo.rs`) because no maintained Rust crate implements CIRCL's
variant (RustCrypto `frodo-kem` 0.1 implements the salted ISO version with
9752-byte ciphertexts). ML-KEM is RustCrypto `ml-kem` 0.3.2.
Tests: `crypto::frodo::tests::nist_kat_digest` (reproduces the SHA-256
CIRCL checks against the FrodoKEM reference KAT file),
`crypto::kem::tests::{frodo_matches_circl, mlkem_matches_circl,
hybrid_derivation_matches_go, wrap_matches_go_and_unwraps,
fresh_wraps_differ_and_foreign_keys_do_not_match}`.
## 3. Fury header
```text
fury-encryption.org/v1\n
-> TYPE ARG ARG\n per stanza; TYPE and ARGs are 1+ bytes of 0x21-0x7e
BODY, 64 base64 columns per line, ending with a line shorter than 64
--- MAC\n MAC = base64 of 32 bytes
```
- Base64: standard alphabet, no padding, non-zero trailing bits rejected,
CR or LF anywhere in an encoded value rejected.
- A body whose encoding fills its last line (the 48-byte wrapped key encodes
to exactly 64 characters) is followed by an empty line; serialisation is
canonical and Rust reproduces Go's bytes exactly.
- Limits: 1 MiB per stanza (all its lines), 16 MiB per header; a line must
end in LF; three bytes of look-ahead decide between a stanza and the
footer; the payload starts immediately after the footer's LF, with any
read-ahead returned first.
- MAC: `HMAC-SHA-256(key = HKDF-SHA-256(file key, salt none, info "header"), message = header up to and including "---")`,
compared in constant time.
- Payload: 16-byte nonce, then STREAM keyed by
`HKDF-SHA-256(file key, salt = nonce, info "payload")`.
Tests: `crypto::fury::tests::{header_vectors_match_go,
header_parser_agrees_with_go, decrypts_go_files}` (the parser's accept or
reject verdict, and the payload bytes left after the header, match Go on
every case in `crypto.json`), `crypto::header::tests::*`,
`tests/interop_crypto.rs` (Go decrypts files Rust wrote, and rejects
truncated, extended and corrupted ones).
## 4. STREAM
- 64 KiB plaintext chunks; ChaCha20-Poly1305; 16-byte tag per chunk.
- Nonce: 12 bytes, bytes 3..11 the big-endian chunk index, last byte `1`
on the final chunk only, `0` otherwise.
- Only the final chunk may be short; it may be empty only when it is the
only chunk (empty plaintext). A plaintext that is an exact multiple of
64 KiB ends with a full-length final chunk.
- Rejected: no final chunk (truncation), an empty final chunk after
others, bytes after the final chunk, any chunk failing authentication,
a payload length no STREAM encoding can have.
- The index never wraps; Rust refuses to go past 2^64 − 1 chunks (Go's
counter is 88 bits; neither limit is reachable in practice).
- Random access (`StreamReaderAt`) authenticates the final chunk when
constructed, then any chunk on demand.
Tests: `crypto::stream::tests::*` (Go vectors for sizes around every
boundary, any write/read step sizes, malformations rejected identically by
the streaming, in-memory and random-access readers, concurrency of the
random-access reader, counter overflow).
## 5. Store
- `DIR/.vault` contains exactly `VAULT 2\n`.
- `DIR/records/xx/ID` and `DIR/objects/xx/ID`: `ID` is 16 bytes as
lowercase hex, `xx` its first byte; all 256 shards of both exist after
`init`. Entries whose names are not exactly that form, or are in the
wrong shard, are ignored.
- Every ciphertext, every shard and both top directories have access and
modification times pinned to the Unix epoch; removals re-pin the shard.
- Record: a Fury file whose plaintext is exactly 4096 bytes: a u32 BE JSON
length (≤ 4092), the JSON, random padding. JSON is Go's `encoding/json`
output for `{"name","seed","size","mode","modified","added"}`: HTML
characters and U+2028/2029 escaped, times RFC 3339 with up to nine
fractional digits, trailing zeros removed, `Z`. Rust writes it
byte-identically.
- Validation on read: name valid (below), seed 64 lowercase hex characters,
size 0..=1 TiB, both times valid RFC 3339; unknown fields refused.
- Names: non-empty UTF-8, ≤ 1024 bytes, already clean (Go `path.Clean`),
relative, not `.`/`..`/`../…`, no backslash, no control characters.
- Object IDs: `HMAC-SHA-256(key = seed, msg = index as big-endian i64)[:16]`.
- Objects: plaintext = own ID (16 bytes), then up to `4 MiB − 16` file
bytes, then random padding to the next power of two from 64 KiB to 4 MiB.
An empty file still has one object. These sizes are plaintext; the
ciphertext adds the header (~14.6 KB) and 16 bytes per 64 KiB.
- Reading an object checks: it opens, its first 16 bytes are its ID, its
length is exactly the padded size for its part, and the whole STREAM
(final chunk, end of file) authenticates.
- Records the key cannot open (no stanza matches) are skipped silently;
ones that open but are malformed are reported and skipped.
- Duplicates (an interrupted replace) are ordered newest `added` first; the
next write of that name removes them all.
- Filler: `init` writes 8–32 entries (or more, to reach `--filler`) of
log-uniform sizes from 1 KiB to `clamp(target/8, 4 MiB, 1 GiB)`, sealed to
a throwaway key, through the same code as `add`.
Tests: `store::types::tests::*` (names, object IDs, padded sizes, object
counts, record JSON byte-identical to Go), `store::tests::reads_go_vault`
(the oracle's fixture vault: real files, decoy file, foreign record),
`store::tests::*`, `tests/cli.rs` lifecycle tests in both directions.
## 6. Locks
`DIR/.lock`, created mode 0600. Unix: POSIX `fcntl` record locks over the
whole file (`F_SETLK` then `F_SETLKW`), shared for readers, exclusive for
writers, released by the kernel when the process dies. Windows:
`LockFileEx` on byte 0, length 1. Other systems: an `O_EXCL` lock file.
Tests: `tests/cli.rs::lock_*` (Rust waits for Go, Go waits for Rust, both
proceed after the holder is killed).
## 7. Agent
Socket `$XDG_RUNTIME_DIR` (else `$TMPDIR`, else `/tmp`) `/vault-UID/agent-`
followed by the first 8 bytes of SHA-256 of the key file's absolute path
(Go `filepath.Abs`, including its use of `$PWD`) in hex. The directory must
be the user's own with mode exactly 0700. One request per connection: `g`
→ 34 bytes (u16 BE suite ID, seed); `q` → the agent wipes the key and
closes. Two-second deadline per request. Tests:
`tests/cli.rs::agent_*` (Rust client with Go agent and the reverse).
## 8. What is deliberately not identical
See `docs/MIGRATION.md` for the full list of intentional behaviour changes.
None of them alters a byte that is written to the vault or the key file
beyond what the Go format already allows (fresh randomness, timestamps).
+1283
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
[package]
name = "vault-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
tar = { version = "0.4.46", default-features = false }
vault = { path = ".." }
# Not part of the main package's workspace.
[workspace]
[profile.release]
debug = 1
[[bin]]
name = "header"
path = "fuzz_targets/header.rs"
test = false
doc = false
bench = false
[[bin]]
name = "stream"
path = "fuzz_targets/stream.rs"
test = false
doc = false
bench = false
[[bin]]
name = "keyfile"
path = "fuzz_targets/keyfile.rs"
test = false
doc = false
bench = false
[[bin]]
name = "record"
path = "fuzz_targets/record.rs"
test = false
doc = false
bench = false
[[bin]]
name = "names"
path = "fuzz_targets/names.rs"
test = false
doc = false
bench = false
[[bin]]
name = "html"
path = "fuzz_targets/html.rs"
test = false
doc = false
bench = false
[[bin]]
name = "tar"
path = "fuzz_targets/tar.rs"
test = false
doc = false
bench = false
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The Fury header parser never panics, and accepts only canonical headers:
//! whatever it accepts re-serialises to exactly the bytes it consumed.
#![no_main]
use std::io::Read;
use vault::crypto::header;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
if let Ok((h, mut rest)) = header::parse(data) {
let mut tail = Vec::new();
rest.read_to_end(&mut tail).unwrap();
let consumed = &data[..data.len() - tail.len()];
assert_eq!(h.marshal(), consumed, "non-canonical header accepted");
}
});
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The mirror's HTML and CSS rewriters never panic, and rewriting nothing
//! returns the input byte for byte.
#![no_main]
use vault::net::html;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let _ = html::tokenize(data);
assert_eq!(html::rewrite_html(data, &mut |_| None, &mut |_| None), data);
assert_eq!(html::rewrite_css(data, false, &mut |_| None), data);
let _ = html::rewrite_html(data, &mut |_| None, &mut |u| Some(format!("x/{}", u.len())));
if let Ok(s) = std::str::from_utf8(data) {
let _ = vault::net::Url::parse(s).map(|u| u.join("../a?b#c"));
}
});
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! PEM decoding never panics, and decoding what it encoded is the identity.
#![no_main]
use vault::keyfile::{decode_pem, encode_pem, parse_body};
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
if let Some(body) = decode_pem(data) {
let _ = parse_body(&body);
assert_eq!(decode_pem(encode_pem(&body).as_bytes()), Some(body));
}
});
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Path validation: a valid vault name is clean, relative and confined; the
//! tar name check only ever yields valid-shaped relative names.
#![no_main]
use vault::store::VaultName;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let Ok(s) = std::str::from_utf8(data) else { return };
if let Some(n) = VaultName::new(s) {
let n = n.as_str();
assert_eq!(vault::util::clean(n), n);
assert!(!n.starts_with('/') && !n.contains('\\') && !n.split('/').any(|c| c == ".." || c == "." || c.is_empty()));
assert!(n.len() <= 1024);
}
if let Some(n) = vault::absorb::safe_name(s) {
assert!(!n.is_empty() && !n.starts_with('/') && !n.split('/').any(|c| c == ".."));
}
});
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Record JSON parsing never panics; an accepted record re-encodes (Go's
//! JSON) to something that parses back to the same values.
#![no_main]
use vault::store::{Id, Record};
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
if let Ok(r) = Record::from_json(data, Id([0; 16])) {
let Some(j) = r.to_json() else { return };
let r2 = Record::from_json(&j, Id([0; 16])).expect("own encoding parses");
assert_eq!(r.name, r2.name);
assert_eq!((r.size, r.mode, r.modified, r.added), (r2.size, r2.mode, r2.modified, r2.added));
assert!(r.seed.same_as(&r2.seed));
}
});
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The streaming, in-memory and random-access STREAM readers agree on every
//! input: the same plaintext or all reject. Inputs are mostly forgeries;
//! the first byte selects a mode that starts from a valid stream instead.
#![no_main]
use std::io::{Read, Write};
use vault::crypto::stream::{StreamReader, StreamReaderAt, StreamWriter, open_all};
const KEY: [u8; 32] = [7; 32];
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let Some((&mode, rest)) = data.split_first() else { return };
let input = if mode & 1 == 1 {
// A valid stream of the given plaintext, then a mutation.
let mut w = StreamWriter::new(&KEY, Vec::new());
let n = rest.len().min(200_000);
w.write_all(&rest[..n]).unwrap();
let mut c = w.finish().unwrap();
if mode & 2 == 2 && !c.is_empty() {
let i = (mode as usize * 7919) % c.len();
c[i] ^= mode | 1;
}
if mode & 4 == 4 {
c.truncate(c.len().saturating_sub(mode as usize % 40));
}
c
} else {
rest.to_vec()
};
let mut a = Vec::new();
let streaming = StreamReader::new(&KEY, &input[..]).read_to_end(&mut a).map(|_| a);
let mut b = input.clone();
let whole = open_all(&KEY, &mut b).map(|n| b[..n].to_vec());
assert_eq!(streaming.is_ok(), whole.is_ok());
if let (Ok(x), Ok(y)) = (&streaming, &whole) {
assert_eq!(x, y);
}
if let Ok(r) = StreamReaderAt::new(&KEY, &input[..], input.len() as u64) {
let mut out = vec![0u8; r.size() as usize];
let got = r.read_at(&mut out, 0).map(|n| out[..n].to_vec());
assert_eq!(got.is_ok(), whole.is_ok());
if let (Ok(x), Ok(y)) = (got, whole) {
assert_eq!(x, y);
}
}
});
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Walking arbitrary tar input as absorb does never panics, and every name
//! it would store is confined.
#![no_main]
use std::io::Read;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let mut a = tar::Archive::new(data);
let Ok(entries) = a.entries() else { return };
for e in entries.take(64) {
let Ok(mut e) = e else { return };
let name = e.path_bytes().into_owned();
if let Some(n) = std::str::from_utf8(&name).ok().and_then(vault::absorb::safe_name) {
assert!(!n.split('/').any(|c| c == ".."));
}
let mut sink = Vec::new();
let _ = e.by_ref().take(1 << 20).read_to_end(&mut sink);
}
});
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Build the dixon-vault .deb, .rpm and Arch packages locally and show what
# is in them. Nothing is signed or published: packaging/repo.sh does that,
# reusing this script's build step.
#
# packaging/package.sh # from the working tree, into dist/packages
# packaging/package.sh v0.6 # from a tag (a clean export of it)
# OUT=/tmp/pkgs ARCHS="amd64 arm64" packaging/package.sh
#
# Needs: the pinned Rust toolchain with cargo-zigbuild and zig (the static
# Linux binaries are cross-built), and go, only to run the pinned nfpm
# packager. dpkg-deb, rpm and zstd are used for inspection when present.
# None of these is needed to run vault itself.
#
# ARCHS package architectures (default below)
# RPM_SIGNING_KEY armoured private key file to sign the .rpm with
# (repo.sh sets it; unset here means unsigned)
set -euo pipefail
# ---- project ---------------------------------------------------------------
NAME=vault # the command
# The package: plain "vault" is HashiCorp Vault in Arch's [extra] and in
# HashiCorp's own apt and dnf repositories, which would win upgrades. Both
# install /usr/bin/vault, so they conflict.
PACKAGE=dixon-vault
CONFLICTS=vault
SUMMARY="Post-quantum encrypted file vault with a decoy passphrase"
HOMEPAGE=https://dixon.cx/vault
LICENSE=GPL-3.0-or-later
MANPAGES=docs/*.1
DOCS="README.md LICENSE SECURITY.md"
# Package architecture -> Rust target (all static musl binaries).
triple() {
case $1 in
amd64) echo x86_64-unknown-linux-musl ;;
arm64) echo aarch64-unknown-linux-musl ;;
arm) echo armv7-unknown-linux-musleabihf ;;
386) echo i686-unknown-linux-musl ;;
riscv64) echo riscv64gc-unknown-linux-musl ;;
*) return 1 ;;
esac
}
# ----------------------------------------------------------------------------
ARCHS=${ARCHS:-"amd64 arm64 arm 386 riscv64"}
NFPM="go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1"
say() { echo "package: $*" >&2; }
die() { say "$*"; exit 1; }
# build_packages SRC OUTDIR VERSION: binaries, then one .deb, .rpm and
# .pkg.tar.zst per architecture, into OUTDIR.
build_packages() {
local src=$1 outdir=$2 version=$3 arch nfarch t work
for tool in cargo cargo-zigbuild zig go; do
command -v $tool >/dev/null || die "needs $tool"
done
work=$(mktemp -d)
mkdir -p "$outdir"
for arch in $ARCHS; do
t=$(triple "$arch") || die "no Rust target for $arch"
nfarch=$arch
[[ $arch == arm ]] && nfarch=arm7
say "building $NAME for $arch ($t)"
flags="--remap-path-prefix=$src=vault"
# zig's musl getrandom() never reaches the kernel on riscv64; have
# the getrandom crate issue the system call itself.
[[ $t == riscv64*-musl ]] && flags+=' --cfg getrandom_backend="linux_raw"'
(cd "$src" && RUSTFLAGS=$flags cargo zigbuild --quiet --release --locked --target "$t") || die "build for $arch failed"
install -D -m 0755 "$src/target/$t/release/$NAME" "$work/bin-$arch/$NAME"
{
cat <<-EOF
name: $PACKAGE
arch: $nfarch
platform: linux
version: $version
release: 1
maintainer: Andy Dixon <andy@dixon.cx>
description: $SUMMARY
homepage: $HOMEPAGE
license: $LICENSE
conflicts: [${CONFLICTS:-}]
contents:
- src: $work/bin-$arch/$NAME
dst: /usr/bin/$NAME
file_info: {mode: 0755}
EOF
for m in $(cd "$src" && ls $MANPAGES); do
printf ' - src: %s\n dst: /usr/share/man/man1/%s\n file_info: {mode: 0644}\n' \
"$src/$m" "$(basename "$m")"
done
for d in $DOCS; do
printf ' - src: %s\n dst: /usr/share/doc/%s/%s\n file_info: {mode: 0644}\n' \
"$src/$d" "$PACKAGE" "$d"
done
if [[ -n ${RPM_SIGNING_KEY:-} ]]; then
printf 'rpm:\n signature:\n key_file: %s\n' "$RPM_SIGNING_KEY"
fi
} >"$work/nfpm-$arch.yaml"
for fmt in deb rpm archlinux; do
$NFPM pkg -f "$work/nfpm-$arch.yaml" -p $fmt -t "$outdir/" >/dev/null
done
done
rm -rf -- "$work"
}
# inspect_packages DIR: list each package's metadata and files.
inspect_packages() {
local p
for p in "$1"/*; do
echo "== $(basename "$p")"
case $p in
*.deb) if command -v dpkg-deb >/dev/null; then dpkg-deb -f "$p" Package Version Architecture Conflicts; dpkg-deb -c "$p"; fi ;;
*.rpm) if command -v rpm >/dev/null; then rpm -qpi "$p" 2>/dev/null | head -12; rpm -qplv "$p"; fi ;;
*.pkg.tar.zst) if command -v zstd >/dev/null; then zstd -dcq "$p" | tar -tvf -; fi ;;
esac
done
}
# When run (not sourced by repo.sh): build from the tree or a tag, inspect.
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
cd "$(git rev-parse --show-toplevel)"
out=${OUT:-dist/packages}
mkdir -p -- "$out"
out=$(cd -- "$out" && pwd -P)
rm -f -- "$out"/*.deb "$out"/*.rpm "$out"/*.pkg.tar.zst
if [[ $# -gt 0 ]]; then
git rev-parse -q --verify "refs/tags/$1" >/dev/null || die "no tag $1"
src=$(mktemp -d)
trap 'rm -rf -- "$src"' EXIT
git archive "$1" | tar -x -C "$src"
version=${1#v}
else
src=$PWD
version=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import sys,json; print(json.load(sys.stdin)["packages"][0]["version"])')
fi
build_packages "$src" "$out" "$version"
inspect_packages "$out"
say "$(ls "$out" | wc -l) packages in $out (unsigned, not published)"
fi
+15 -61
View File
@@ -10,37 +10,25 @@
# Every index is rebuilt over all the packages in the repository, so other
# projects publishing there with their copy of this script are kept.
#
# Needs: go, docker, gpg, apt-ftparchive (apt-utils), flock.
# Needs: the pinned Rust toolchain with cargo-zigbuild and zig, go (to run
# nfpm), docker, gpg, apt-ftparchive (apt-utils), flock.
# REPO_DIR where the repository lives (default below)
# REPO_GNUPGHOME GnuPG home holding the signing key (default below)
# ARCHS Go architectures to package (default below)
# ARCHS architectures to package (see packaging/package.sh)
#
# To build and inspect packages without signing or publishing, run
# packaging/package.sh instead.
#
# The same script, with its own project block, is in ../mapsize/packaging.
set -euo pipefail
# ---- project ---------------------------------------------------------------
NAME=vault # the command
# The package: plain "vault" is HashiCorp Vault in Arch's [extra] and in
# HashiCorp's own apt and dnf repositories, which would win upgrades. Both
# install /usr/bin/vault, so they conflict.
PACKAGE=dixon-vault
CONFLICTS=vault
SUMMARY="Post-quantum encrypted file vault with a decoy passphrase"
HOMEPAGE=https://dixon.cx/vault
LICENSE=GPL-3.0-or-later
MANPAGES=docs/*.1
DOCS="README.md LICENSE"
# build OUT: build the binary to OUT, in the source tree, with GOOS, GOARCH
# and GOARM set and VERSION the release.
build() {
CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags='-s -w -buildid=' -o "$1" .
}
# ----------------------------------------------------------------------------
# The project (package name, contents, architectures) and the build live in
# packaging/package.sh, which can also be run on its own to build and inspect
# packages without signing or publishing anything.
. "$(dirname "$0")/package.sh"
REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html}
REPO_GNUPGHOME=${REPO_GNUPGHOME:-$HOME/.config/repo.dixon.cx/gnupg}
ARCHS=${ARCHS:-"amd64 arm64 arm 386 riscv64"}
NFPM="go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1"
SUITE=stable
COMPONENT=main
ARCH_REPO=dixon # the pacman repository name: [dixon]
@@ -48,7 +36,7 @@ ARCH_REPO=dixon # the pacman repository name: [dixon]
say() { echo "repo: $*" >&2; }
die() { say "$*"; exit 1; }
for tool in go docker gpg apt-ftparchive flock; do
for tool in cargo cargo-zigbuild zig go docker gpg apt-ftparchive flock; do
command -v $tool >/dev/null || die "needs $tool"
done
[[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist"
@@ -64,47 +52,13 @@ export VERSION
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
git archive "$TAG" | tar -x -C "$WORK" --one-top-level=src
mkdir -p "$WORK/src"
git archive "$TAG" | tar -x -C "$WORK/src"
gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc"
# ---- build -----------------------------------------------------------------
# ---- build ----------------------------------------------------------------
say "building $PACKAGE $VERSION for $ARCHS"
mkdir -p "$WORK/out"
for arch in $ARCHS; do
goarm= nfarch=$arch
[[ $arch == arm ]] && goarm=7 nfarch=arm7
(cd "$WORK/src" && GOOS=linux GOARCH=$arch GOARM=$goarm build "$WORK/bin-$arch/$NAME")
{
cat <<-EOF
name: $PACKAGE
arch: $nfarch
platform: linux
version: $VERSION
release: 1
maintainer: Andy Dixon <andy@dixon.cx>
description: $SUMMARY
homepage: $HOMEPAGE
license: $LICENSE
conflicts: [${CONFLICTS:-}]
contents:
- src: $WORK/bin-$arch/$NAME
dst: /usr/bin/$NAME
file_info: {mode: 0755}
EOF
for m in $(cd "$WORK/src" && ls $MANPAGES); do
printf ' - src: %s\n dst: /usr/share/man/man1/%s\n file_info: {mode: 0644}\n' \
"$WORK/src/$m" "$(basename "$m")"
done
for d in $DOCS; do
printf ' - src: %s\n dst: /usr/share/doc/%s/%s\n file_info: {mode: 0644}\n' \
"$WORK/src/$d" "$PACKAGE" "$d"
done
printf 'rpm:\n signature:\n key_file: %s\n' "$WORK/signing.asc"
} >"$WORK/nfpm-$arch.yaml"
for fmt in deb rpm archlinux; do
$NFPM pkg -f "$WORK/nfpm-$arch.yaml" -p $fmt -t "$WORK/out/" >/dev/null
done
done
RPM_SIGNING_KEY=$WORK/signing.asc build_packages "$WORK/src" "$WORK/out" "$VERSION"
# ---- publish ---------------------------------------------------------------
# One publisher at a time: the indexes cover every project's packages.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Runtime smoke test of one vault binary, optionally under an emulator:
# create a key and vault, store files across object boundaries, read them
# back, then (if a reference binary is given) check the reference can read
# what this binary wrote and the other way round.
#
# scripts/smoke.sh BINARY [REFERENCE_BINARY]
# EMU="qemu-aarch64" scripts/smoke.sh dist/vault-linux-arm64 target/release/vault
#
# Uses a private temporary directory, removed afterwards. The passphrase is
# a public test value.
set -euo pipefail
bin=$(realpath "$1")
ref=${2:+$(realpath "$2")}
emu=${EMU:-}
work=$(mktemp -d "${TMPDIR:-/tmp}/vault-smoke.XXXXXX")
trap 'rm -rf -- "$work"' EXIT
cd "$work"
export VAULT_PASSPHRASE=smoke-test-passphrase VAULT_KEY=$work/key VAULT_DIR=$work/store
export XDG_RUNTIME_DIR=$work/run
mkdir -m 700 run
v() { $emu "$bin" "$@"; }
v genkey 2>/dev/null
v init 2>/dev/null
head -c $((4 * 1024 * 1024 + 123)) /dev/urandom >big
printf 'small\n' >small
: >empty
v add big 2>/dev/null
v add small dir 2>/dev/null
v add empty dir 2>/dev/null
[[ $(v list | wc -l) == 3 ]] || { echo "FAIL list"; exit 1; }
v get big -o big.out 2>/dev/null && cmp big big.out
v get dir/small -o small.out 2>/dev/null && cmp small small.out
if [[ -n $ref ]]; then
"$ref" get big -o big.ref 2>/dev/null && cmp big big.ref
printf 'from the reference\n' >r.txt
"$ref" add r.txt 2>/dev/null
v get r.txt -o r.out 2>/dev/null && cmp r.txt r.out
fi
v rm -rf dir 2>/dev/null
[[ $(v list | wc -l) == $([[ -n $ref ]] && echo 2 || echo 1) ]] || { echo "FAIL rm"; exit 1; }
echo "ok $(basename "$bin")${emu:+ (under $emu)}"
+4 -3
View File
@@ -90,9 +90,10 @@ fn mtime<R: Read>(entry: &mut tar::Entry<R>) -> Result<Instant> {
if let Ok(Some(exts)) = entry.pax_extensions() {
for ext in exts.flatten() {
if ext.key_bytes() == b"mtime"
&& let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time) {
return Ok(t);
}
&& let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time)
{
return Ok(t);
}
}
}
let secs = entry
+17 -7
View File
@@ -24,12 +24,18 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
pub const AGENT_CMD: &str = "__agent";
#[cfg_attr(not(unix), allow(dead_code))]
const GET: u8 = b'g';
#[cfg_attr(not(unix), allow(dead_code))]
const QUIT: u8 = b'q';
#[cfg_attr(not(unix), allow(dead_code))]
const REPLY: usize = 2 + crate::crypto::kem::SEED_SIZE;
#[cfg_attr(not(unix), allow(dead_code))]
const TIMEOUT: Duration = Duration::from_secs(2);
/// How long `unlock` waits for a starting agent to say it is ready.
#[cfg_attr(not(unix), allow(dead_code))]
const START_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg_attr(not(unix), allow(dead_code))]
const SECRET_ENVS: [&str; 2] = ["VAULT_PASSPHRASE", "VAULT_DECOY_PASSPHRASE"];
/// Go's `filepath.Abs`: lexical, with `os.Getwd`'s preference for `$PWD`.
@@ -53,10 +59,12 @@ fn go_getwd() -> std::io::Result<PathBuf> {
let dot = std::fs::metadata(".")?;
if let Some(pwd) = std::env::var_os("PWD").map(PathBuf::from)
&& pwd.is_absolute()
&& let Ok(m) = std::fs::metadata(&pwd)
&& m.dev() == dot.dev() && m.ino() == dot.ino() {
return Ok(pwd);
}
&& let Ok(m) = std::fs::metadata(&pwd)
&& m.dev() == dot.dev()
&& m.ino() == dot.ino()
{
return Ok(pwd);
}
std::env::current_dir()
}
@@ -265,9 +273,11 @@ mod imp {
// Remove the socket only if it is still the one this agent made.
use std::os::unix::fs::MetadataExt;
if let Ok(m) = std::fs::symlink_metadata(sock)
&& m.ino() == ours.ino() && m.dev() == ours.dev() {
let _ = std::fs::remove_file(sock);
}
&& m.ino() == ours.ino()
&& m.dev() == ours.dev()
{
let _ = std::fs::remove_file(sock);
}
};
{
use std::os::unix::fs::PermissionsExt;
+8 -6
View File
@@ -94,9 +94,10 @@ macro_rules! say {
fn out(line: &str) {
let mut o = std::io::stdout().lock();
if let Err(e) = o.write_all(line.as_bytes()).and_then(|_| o.flush())
&& e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(128 + 13);
}
&& e.kind() == std::io::ErrorKind::BrokenPipe
{
std::process::exit(128 + 13);
}
}
#[derive(Default)]
@@ -319,9 +320,10 @@ fn default_path(flag: &Option<String>, env: &str, name: &str) -> Result<PathBuf>
/// environment, else from the key file with the passphrase.
fn key_identity(key: &Path) -> Result<crate::crypto::kem::Identity> {
if std::env::var_os(PASSPHRASE_ENV).is_none()
&& let Ok(id) = agent::identity(key) {
return Ok(id);
}
&& let Ok(id) = agent::identity(key)
{
return Ok(id);
}
let pass = read_passphrase("Enter passphrase:")?;
keyfile::unlock(key, pass.expose()).map_err(|e| Error::new(e.to_string()))
}
+2 -2
View File
@@ -29,8 +29,8 @@
//! it; that file holds plaintext until it is removed, on every path, once
//! stored. HTML and CSS for rewriting are held in memory instead.
mod html;
mod url;
pub mod html;
pub mod url;
#[cfg(test)]
mod tests;
+15 -1
View File
@@ -81,7 +81,12 @@ fn unescape(s: &str, mode: Mode) -> Result<String, String> {
b'%' => {
let (h, l) = match (b.get(i + 1).copied().and_then(unhex), b.get(i + 2).copied().and_then(unhex)) {
(Some(h), Some(l)) => (h, l),
_ => return Err(format!("invalid URL escape {:?}", &s[i..(i + 3).min(s.len())])),
_ => {
return Err(format!(
"invalid URL escape {:?}",
String::from_utf8_lossy(&b[i..(i + 3).min(b.len())])
));
}
};
if mode == Mode::Host && h < 8 && &s[i..i + 3] != "%25" {
return Err(format!("invalid URL escape {:?}", &s[i..i + 3]));
@@ -447,6 +452,15 @@ pub(crate) mod tests {
assert_eq!(super::super::key(&u), s("key"), "{ctx} key");
}
/// Found by fuzzing: a bad escape before a multi-byte character used to
/// slice through the character and panic.
#[test]
fn bad_escape_before_multibyte_character() {
assert!(Url::parse("/K%\u{8e3}").is_err());
assert!(Url::parse("http://h/%é").is_err());
assert!(Url::parse("http://h/%2").is_err());
}
#[test]
fn parse_matches_go() {
for v in vectors()["parse"].as_array().unwrap() {
+1
View File
@@ -36,6 +36,7 @@ pub fn create_private_dir_all(p: &Path) -> io::Result<()> {
}
pub fn create_private_dir(p: &Path) -> io::Result<()> {
#[cfg_attr(not(unix), allow(unused_mut))]
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
b.mode(0o700);
+1
View File
@@ -14,6 +14,7 @@
//! `/proc/PID/mem` access. Other Unix systems get the rlimit only. Windows
//! is not supported by the agent (see SECURITY.md).
#[cfg(unix)]
use std::io;
#[cfg(unix)]
+6 -1
View File
@@ -2,11 +2,15 @@
//! The real binaries, end to end: the Rust CLI on its own, and the Rust
//! and Go CLIs sharing one key, one vault, one lock and one agent.
// The lock and agent tests are Unix-only; their imports are unused elsewhere.
#![cfg_attr(not(unix), allow(unused_imports))]
mod common;
use std::io::Write;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::path::Path;
use std::path::PathBuf;
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
@@ -286,6 +290,7 @@ fn absorb_from_stdin() {
/// Starts `absorb` and feeds it one byte: it takes the exclusive lock and
/// then holds it until its standard input closes or it is killed. The
/// caller kills and waits for it.
#[cfg(unix)]
#[allow(clippy::zombie_processes)]
fn hold_lock(e: &Env, bin: &str) -> Child {
let mut c = e