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:
@@ -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
|
||||||
@@ -7,3 +7,7 @@
|
|||||||
/docs/guide/vault-guide.pdf
|
/docs/guide/vault-guide.pdf
|
||||||
/docs/guide/*.log
|
/docs/guide/*.log
|
||||||
/target
|
/target
|
||||||
|
fuzz/target
|
||||||
|
fuzz/corpus
|
||||||
|
fuzz/artifacts
|
||||||
|
fuzz/coverage
|
||||||
|
|||||||
@@ -35,3 +35,8 @@ rustix = { version = "1.1.5", features = ["fs", "process", "mm", "termios", "eve
|
|||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[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"] }
|
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
@@ -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.
|
||||||
@@ -1,48 +1,143 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# 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).
|
# Binaries keep the Go-era names, vault-OS-ARCH[.exe], and are stripped
|
||||||
# iOS and most Android targets are skipped (they need cgo); see below.
|
# release builds from the pinned toolchain (rust-toolchain.toml) and the
|
||||||
# 32-bit ARM Linux is built twice: armv6 (Raspberry Pi Zero/1) and armv7.
|
# locked dependencies (Cargo.lock), with build paths remapped so the output
|
||||||
# SHA256SUMS is written next to the binaries.
|
# 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
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
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}
|
out=${OUT:-dist}
|
||||||
# iOS and 32-bit/x86 Android need cgo and a vendor SDK. android/arm64 builds
|
jobs=${JOBS:-1}
|
||||||
# a static binary that runs in Termux.
|
targets=${TARGETS:-$(table | awk '{print $1}')}
|
||||||
targets=${TARGETS:-$(go tool dist list | grep -vE '^ios/|^android/(386|amd64|arm)$')}
|
|
||||||
jobs=${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}
|
|
||||||
|
|
||||||
rm -rf "$out"
|
# Never clean anything but our own output, and never in a directory that
|
||||||
mkdir -p "$out"
|
# 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() {
|
build_one() {
|
||||||
local os=${1%/*} arch=${1#*/} variants=("") name ext=""
|
local want=$1 row os triple method name ext="" tdir bin
|
||||||
[[ $os == windows ]] && ext=.exe
|
row=$(table | awk -v t="$want" '$1 == t')
|
||||||
[[ $arch == wasm ]] && ext=.wasm
|
if [[ -z $row ]]; then
|
||||||
[[ $os/$arch == linux/arm ]] && variants=(6 7)
|
if grep -qw -- "$want" <<<"$unsupported"; then
|
||||||
for v in "${variants[@]}"; do
|
echo "UNSUPPORTED $want: no stable Rust target; see docs/PLATFORMS.md" >&2
|
||||||
name=vault-$os-$arch${v:+v$v}$ext
|
else
|
||||||
if ! GOOS=$os GOARCH=$arch GOARM=${v:-} CGO_ENABLED=0 \
|
echo "UNKNOWN $want" >&2
|
||||||
go build -trimpath -buildvcs=false -ldflags='-s -w -buildid=' -o "$OUT_DIR/$name" . 2>"$OUT_DIR/$name.log"; then
|
fi
|
||||||
echo "FAILED $name" >&2
|
|
||||||
cat "$OUT_DIR/$name.log" >&2
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
rm "$OUT_DIR/$name.log"
|
read -r _ triple method <<<"$row"
|
||||||
echo "built $name"
|
os=${want%/*}
|
||||||
done
|
[[ $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 -f build_one table
|
||||||
export OUT_DIR=$out
|
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"
|
cd -- "$out"
|
||||||
if command -v sha256sum >/dev/null; then sha256sum vault-* >SHA256SUMS; else shasum -a 256 vault-* >SHA256SUMS; fi
|
if ls vault-* >/dev/null 2>&1; then
|
||||||
echo "$(ls vault-* | wc -l | tr -d ' ') binaries in $out"
|
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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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).
|
||||||
Generated
+1283
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 == ".."));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Executable
+140
@@ -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
@@ -10,37 +10,25 @@
|
|||||||
# Every index is rebuilt over all the packages in the repository, so other
|
# Every index is rebuilt over all the packages in the repository, so other
|
||||||
# projects publishing there with their copy of this script are kept.
|
# 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_DIR where the repository lives (default below)
|
||||||
# REPO_GNUPGHOME GnuPG home holding the signing key (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.
|
# The same script, with its own project block, is in ../mapsize/packaging.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---- project ---------------------------------------------------------------
|
# The project (package name, contents, architectures) and the build live in
|
||||||
NAME=vault # the command
|
# packaging/package.sh, which can also be run on its own to build and inspect
|
||||||
# The package: plain "vault" is HashiCorp Vault in Arch's [extra] and in
|
# packages without signing or publishing anything.
|
||||||
# HashiCorp's own apt and dnf repositories, which would win upgrades. Both
|
. "$(dirname "$0")/package.sh"
|
||||||
# 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" .
|
|
||||||
}
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html}
|
REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html}
|
||||||
REPO_GNUPGHOME=${REPO_GNUPGHOME:-$HOME/.config/repo.dixon.cx/gnupg}
|
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
|
SUITE=stable
|
||||||
COMPONENT=main
|
COMPONENT=main
|
||||||
ARCH_REPO=dixon # the pacman repository name: [dixon]
|
ARCH_REPO=dixon # the pacman repository name: [dixon]
|
||||||
@@ -48,7 +36,7 @@ ARCH_REPO=dixon # the pacman repository name: [dixon]
|
|||||||
say() { echo "repo: $*" >&2; }
|
say() { echo "repo: $*" >&2; }
|
||||||
die() { say "$*"; exit 1; }
|
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"
|
command -v $tool >/dev/null || die "needs $tool"
|
||||||
done
|
done
|
||||||
[[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist"
|
[[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist"
|
||||||
@@ -64,47 +52,13 @@ export VERSION
|
|||||||
|
|
||||||
WORK=$(mktemp -d)
|
WORK=$(mktemp -d)
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
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"
|
gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc"
|
||||||
|
|
||||||
# ---- build -----------------------------------------------------------------
|
# ---- build ----------------------------------------------------------------
|
||||||
say "building $PACKAGE $VERSION for $ARCHS"
|
say "building $PACKAGE $VERSION for $ARCHS"
|
||||||
mkdir -p "$WORK/out"
|
RPM_SIGNING_KEY=$WORK/signing.asc build_packages "$WORK/src" "$WORK/out" "$VERSION"
|
||||||
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
|
|
||||||
|
|
||||||
# ---- publish ---------------------------------------------------------------
|
# ---- publish ---------------------------------------------------------------
|
||||||
# One publisher at a time: the indexes cover every project's packages.
|
# One publisher at a time: the indexes cover every project's packages.
|
||||||
|
|||||||
Executable
+43
@@ -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)}"
|
||||||
+2
-1
@@ -90,7 +90,8 @@ fn mtime<R: Read>(entry: &mut tar::Entry<R>) -> Result<Instant> {
|
|||||||
if let Ok(Some(exts)) = entry.pax_extensions() {
|
if let Ok(Some(exts)) = entry.pax_extensions() {
|
||||||
for ext in exts.flatten() {
|
for ext in exts.flatten() {
|
||||||
if ext.key_bytes() == b"mtime"
|
if ext.key_bytes() == b"mtime"
|
||||||
&& let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time) {
|
&& let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time)
|
||||||
|
{
|
||||||
return Ok(t);
|
return Ok(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -24,12 +24,18 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub const AGENT_CMD: &str = "__agent";
|
pub const AGENT_CMD: &str = "__agent";
|
||||||
|
#[cfg_attr(not(unix), allow(dead_code))]
|
||||||
const GET: u8 = b'g';
|
const GET: u8 = b'g';
|
||||||
|
#[cfg_attr(not(unix), allow(dead_code))]
|
||||||
const QUIT: u8 = b'q';
|
const QUIT: u8 = b'q';
|
||||||
|
#[cfg_attr(not(unix), allow(dead_code))]
|
||||||
const REPLY: usize = 2 + crate::crypto::kem::SEED_SIZE;
|
const REPLY: usize = 2 + crate::crypto::kem::SEED_SIZE;
|
||||||
|
#[cfg_attr(not(unix), allow(dead_code))]
|
||||||
const TIMEOUT: Duration = Duration::from_secs(2);
|
const TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
/// How long `unlock` waits for a starting agent to say it is ready.
|
/// 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);
|
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"];
|
const SECRET_ENVS: [&str; 2] = ["VAULT_PASSPHRASE", "VAULT_DECOY_PASSPHRASE"];
|
||||||
|
|
||||||
/// Go's `filepath.Abs`: lexical, with `os.Getwd`'s preference for `$PWD`.
|
/// Go's `filepath.Abs`: lexical, with `os.Getwd`'s preference for `$PWD`.
|
||||||
@@ -54,7 +60,9 @@ fn go_getwd() -> std::io::Result<PathBuf> {
|
|||||||
if let Some(pwd) = std::env::var_os("PWD").map(PathBuf::from)
|
if let Some(pwd) = std::env::var_os("PWD").map(PathBuf::from)
|
||||||
&& pwd.is_absolute()
|
&& pwd.is_absolute()
|
||||||
&& let Ok(m) = std::fs::metadata(&pwd)
|
&& let Ok(m) = std::fs::metadata(&pwd)
|
||||||
&& m.dev() == dot.dev() && m.ino() == dot.ino() {
|
&& m.dev() == dot.dev()
|
||||||
|
&& m.ino() == dot.ino()
|
||||||
|
{
|
||||||
return Ok(pwd);
|
return Ok(pwd);
|
||||||
}
|
}
|
||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
@@ -265,7 +273,9 @@ mod imp {
|
|||||||
// Remove the socket only if it is still the one this agent made.
|
// Remove the socket only if it is still the one this agent made.
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
if let Ok(m) = std::fs::symlink_metadata(sock)
|
if let Ok(m) = std::fs::symlink_metadata(sock)
|
||||||
&& m.ino() == ours.ino() && m.dev() == ours.dev() {
|
&& m.ino() == ours.ino()
|
||||||
|
&& m.dev() == ours.dev()
|
||||||
|
{
|
||||||
let _ = std::fs::remove_file(sock);
|
let _ = std::fs::remove_file(sock);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-2
@@ -94,7 +94,8 @@ macro_rules! say {
|
|||||||
fn out(line: &str) {
|
fn out(line: &str) {
|
||||||
let mut o = std::io::stdout().lock();
|
let mut o = std::io::stdout().lock();
|
||||||
if let Err(e) = o.write_all(line.as_bytes()).and_then(|_| o.flush())
|
if let Err(e) = o.write_all(line.as_bytes()).and_then(|_| o.flush())
|
||||||
&& e.kind() == std::io::ErrorKind::BrokenPipe {
|
&& e.kind() == std::io::ErrorKind::BrokenPipe
|
||||||
|
{
|
||||||
std::process::exit(128 + 13);
|
std::process::exit(128 + 13);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +320,8 @@ fn default_path(flag: &Option<String>, env: &str, name: &str) -> Result<PathBuf>
|
|||||||
/// environment, else from the key file with the passphrase.
|
/// environment, else from the key file with the passphrase.
|
||||||
fn key_identity(key: &Path) -> Result<crate::crypto::kem::Identity> {
|
fn key_identity(key: &Path) -> Result<crate::crypto::kem::Identity> {
|
||||||
if std::env::var_os(PASSPHRASE_ENV).is_none()
|
if std::env::var_os(PASSPHRASE_ENV).is_none()
|
||||||
&& let Ok(id) = agent::identity(key) {
|
&& let Ok(id) = agent::identity(key)
|
||||||
|
{
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
let pass = read_passphrase("Enter passphrase:")?;
|
let pass = read_passphrase("Enter passphrase:")?;
|
||||||
|
|||||||
+2
-2
@@ -29,8 +29,8 @@
|
|||||||
//! it; that file holds plaintext until it is removed, on every path, once
|
//! it; that file holds plaintext until it is removed, on every path, once
|
||||||
//! stored. HTML and CSS for rewriting are held in memory instead.
|
//! stored. HTML and CSS for rewriting are held in memory instead.
|
||||||
|
|
||||||
mod html;
|
pub mod html;
|
||||||
mod url;
|
pub mod url;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
+15
-1
@@ -81,7 +81,12 @@ fn unescape(s: &str, mode: Mode) -> Result<String, String> {
|
|||||||
b'%' => {
|
b'%' => {
|
||||||
let (h, l) = match (b.get(i + 1).copied().and_then(unhex), b.get(i + 2).copied().and_then(unhex)) {
|
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),
|
(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" {
|
if mode == Mode::Host && h < 8 && &s[i..i + 3] != "%25" {
|
||||||
return Err(format!("invalid URL escape {:?}", &s[i..i + 3]));
|
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");
|
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]
|
#[test]
|
||||||
fn parse_matches_go() {
|
fn parse_matches_go() {
|
||||||
for v in vectors()["parse"].as_array().unwrap() {
|
for v in vectors()["parse"].as_array().unwrap() {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn create_private_dir_all(p: &Path) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_private_dir(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();
|
let mut b = std::fs::DirBuilder::new();
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
b.mode(0o700);
|
b.mode(0o700);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
//! `/proc/PID/mem` access. Other Unix systems get the rlimit only. Windows
|
//! `/proc/PID/mem` access. Other Unix systems get the rlimit only. Windows
|
||||||
//! is not supported by the agent (see SECURITY.md).
|
//! is not supported by the agent (see SECURITY.md).
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
+6
-1
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
//! The real binaries, end to end: the Rust CLI on its own, and the Rust
|
//! 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.
|
//! 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;
|
mod common;
|
||||||
|
|
||||||
use std::io::Write;
|
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::process::{Child, Command, Output, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
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
|
/// 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
|
/// then holds it until its standard input closes or it is killed. The
|
||||||
/// caller kills and waits for it.
|
/// caller kills and waits for it.
|
||||||
|
#[cfg(unix)]
|
||||||
#[allow(clippy::zombie_processes)]
|
#[allow(clippy::zombie_processes)]
|
||||||
fn hold_lock(e: &Env, bin: &str) -> Child {
|
fn hold_lock(e: &Env, bin: &str) -> Child {
|
||||||
let mut c = e
|
let mut c = e
|
||||||
|
|||||||
Reference in New Issue
Block a user