Add unlock --idle, and packaging/repo.sh for repo.dixon.cx

vault unlock --idle counts the duration from the last command that used
the key rather than from the unlock: the agent resets its timer each
time it hands the key out. The agent loop no longer needs its own
goroutine.

packaging/repo.sh builds the dixon-vault .deb, .rpm and Arch packages
from a release tag (a clean git archive) with nfpm for amd64, arm64,
armv7, 386 and riscv64, then publishes them to the apt, dnf and pacman
repositories at repo.dixon.cx: apt-ftparchive with a signed InRelease,
createrepo_c (in a Fedora container) with signed RPMs and repomd.xml,
and repo-add (in an Arch container) with signed packages and database.
Indexes cover every package in the repository, under a lock, so other
projects publishing with their copy of the script are kept. The package
is dixon-vault because vault is HashiCorp Vault in Arch's [extra] and
HashiCorp's own repositories; it conflicts with vault.

README, guide and CLAUDE.md point at the repository.
This commit is contained in:
Andy Dixon
2026-10-03 09:44:49 +01:00
parent ec204f0ea2
commit 4c1507c463
8 changed files with 334 additions and 47 deletions
+6 -2
View File
@@ -15,8 +15,9 @@ root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
folder, asks unless `-f`), `unlock`/`lock`. folder, asks unless `-f`), `unlock`/`lock`.
- `agent*.go` - `vault unlock` re-executes itself as a hidden `__agent` - `agent*.go` - `vault unlock` re-executes itself as a hidden `__agent`
process holding the suite ID and seed (pipe in, mlocked, undumpable) and process holding the suite ID and seed (pipe in, mlocked, undumpable) and
serving them on a Unix socket in a 0700 per-user dir until a hard serving them on a Unix socket in a 0700 per-user dir until a deadline
deadline. `keyIdentity` asks it first unless `$VAULT_PASSPHRASE` is set. (fixed, or with `--idle` reset on each use). `keyIdentity` asks it first
unless `$VAULT_PASSPHRASE` is set.
- `keyfile.go` - key file with two passphrase slots (Argon2id + - `keyfile.go` - key file with two passphrase slots (Argon2id +
XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second
slot is either a decoy identity or random bytes; slot order is random. slot is either a decoy identity or random bytes; slot order is random.
@@ -30,6 +31,9 @@ root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
readers shared, taken after the passphrase prompt. readers shared, taken after the passphrase prompt.
- `docs/guide` - LaTeX user guide (`build-pdf.sh` via Tectonic; `shots.py` - `docs/guide` - LaTeX user guide (`build-pdf.sh` via Tectonic; `shots.py`
re-shoots screenshots from the real binary). Keep it in step with the CLI. re-shoots screenshots from the real binary). Keep it in step with the CLI.
- `packaging/repo.sh [TAG]` - builds dixon-vault .deb/.rpm/Arch packages
from a tag with nfpm and publishes them, signed, to the apt/dnf/pacman
repo at repo.dixon.cx (same script, other project block, in ../mapsize).
- `build.sh` - cross-compiles every `go tool dist list` target except - `build.sh` - cross-compiles every `go tool dist list` target except
iOS and android/386, amd64, arm (cgo-only). iOS and android/386, amd64, arm (cgo-only).
- `internal/pqcrypt` - file encryption (header + STREAM ChaCha20-Poly1305), - `internal/pqcrypt` - file encryption (header + STREAM ChaCha20-Poly1305),
+13 -1
View File
@@ -11,6 +11,16 @@ whatever the file's size. Object names derive from a secret seed in the
record, so nothing on disk links a file's pieces. Records and objects are record, so nothing on disk links a file's pieces. Records and objects are
spread over 256 subfolders (`records/3f/3fa9…`) to keep directories small. spread over 256 subfolders (`records/3f/3fa9…`) to keep directories small.
## Install
On Debian, Ubuntu, Fedora, RHEL, openSUSE and Arch, install the
`dixon-vault` package from the signed repository at repo.dixon.cx; see
https://dixon.cx/repo for the two or three commands. (The package isn't
called `vault` because that name is HashiCorp Vault's.) Every platform also
has a single binary at https://download.dixon.cx/vault/.
Releases are packaged and published with `packaging/repo.sh [TAG]`.
## Build ## Build
```sh ```sh
@@ -65,6 +75,7 @@ vault rm old/report.pdf
vault rm -r old # a whole folder; lists it and asks first vault rm -r old # a whole folder; lists it and asks first
vault rm -rf old # the same, without asking vault rm -rf old # the same, without asking
vault unlock # remember the key for 5 minutes (--for 30m) vault unlock # remember the key for 5 minutes (--for 30m)
vault unlock --idle # ... or until 5 minutes go by unused
vault lock # forget it now vault lock # forget it now
``` ```
@@ -85,7 +96,8 @@ scripts.
`vault unlock` asks once, then starts a helper process, like `ssh-agent`, `vault unlock` asks once, then starts a helper process, like `ssh-agent`,
that holds the unlocked key in memory until a hard deadline (5 minutes by that holds the unlocked key in memory until a hard deadline (5 minutes by
default) or `vault lock`. Until then commands skip the prompt, unless default; with `--idle`, 5 minutes since a command last used it) or
`vault lock`. Until then commands skip the prompt, unless
`$VAULT_PASSPHRASE` is set, which always wins. The key reaches the helper `$VAULT_PASSPHRASE` is set, which always wins. The key reaches the helper
over a pipe and never touches the disk; the helper's memory is locked over a pipe and never touches the disk; the helper's memory is locked
against swapping, it cannot be core-dumped (or, on Linux, debugged), and it against swapping, it cannot be core-dumped (or, on Linux, debugged), and it
+33 -31
View File
@@ -26,7 +26,8 @@ import (
// seed over a pipe; it never touches the disk. The agent serves them on a // seed over a pipe; it never touches the disk. The agent serves them on a
// Unix socket in a directory only this user can enter, keeps them in locked, // Unix socket in a directory only this user can enter, keeps them in locked,
// undumpable memory where the platform allows, and wipes them and exits at // undumpable memory where the platform allows, and wipes them and exits at
// a fixed deadline, or on `vault lock`. Using the agent never extends it. // a deadline, or on `vault lock`. The deadline is fixed, or with --idle it
// moves on each time a command uses the key.
const ( const (
agentCmd = "__agent" // hidden: the agent process itself agentCmd = "__agent" // hidden: the agent process itself
@@ -104,8 +105,8 @@ func agentStop(keyPath string) bool {
} }
// agentStart replaces any agent for keyPath with one holding id until ttl // agentStart replaces any agent for keyPath with one holding id until ttl
// has passed. // has passed, since it started or, with idle, since the key was last used.
func agentStart(keyPath string, id *pqcrypt.PQIdentity, ttl time.Duration) error { func agentStart(keyPath string, id *pqcrypt.PQIdentity, ttl time.Duration, idle bool) error {
sock, err := agentSocket(keyPath) sock, err := agentSocket(keyPath)
if err != nil { if err != nil {
return err return err
@@ -115,7 +116,11 @@ func agentStart(keyPath string, id *pqcrypt.PQIdentity, ttl time.Duration) error
if err != nil { if err != nil {
return err return err
} }
cmd := exec.Command(self, agentCmd, sock, ttl.String()) mode := "fixed"
if idle {
mode = "idle"
}
cmd := exec.Command(self, agentCmd, sock, ttl.String(), mode)
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
return err return err
@@ -150,12 +155,13 @@ func agentStart(keyPath string, id *pqcrypt.PQIdentity, ttl time.Duration) error
// runAgent is the agent process: it reads the key from in, says "ok" or // runAgent is the agent process: it reads the key from in, says "ok" or
// why not on out and closes it, then serves the key on sock until ttl has // why not on out and closes it, then serves the key on sock until ttl has
// passed or it is told to quit. // passed, since it started or with mode "idle" since the key was last
func runAgent(sock, ttlArg string, in io.Reader, out io.WriteCloser) { // handed out, or until it is told to quit.
func runAgent(sock, ttlArg, mode string, in io.Reader, out io.WriteCloser) {
report := func(msg string) { io.WriteString(out, msg); out.Close() } report := func(msg string) { io.WriteString(out, msg); out.Close() }
ttl, err := time.ParseDuration(ttlArg) ttl, err := time.ParseDuration(ttlArg)
if err != nil || ttl <= 0 { if err != nil || ttl <= 0 || mode != "fixed" && mode != "idle" {
report("bad duration") report("bad duration or mode")
return return
} }
secret := make([]byte, agentReply) secret := make([]byte, agentReply)
@@ -175,44 +181,40 @@ func runAgent(sock, ttlArg string, in io.Reader, out io.WriteCloser) {
} }
os.Chmod(sock, 0o600) os.Chmod(sock, 0o600)
report("ok") report("ok")
done := make(chan struct{}) expiry := time.AfterFunc(ttl, func() { l.Close() })
time.AfterFunc(ttl, func() { l.Close() }) for {
go func() { c, err := l.Accept()
defer close(done) if err != nil {
for { break
c, err := l.Accept()
if err != nil {
return
}
if !serveAgent(c, secret) {
l.Close()
c.Close()
return
}
c.Close()
} }
}() op := serveAgent(c, secret)
<-done c.Close()
if op == agentQuit {
break
}
if op == agentGet && mode == "idle" {
expiry.Reset(ttl)
}
}
l.Close()
clear(secret) clear(secret)
os.Remove(sock) os.Remove(sock)
} }
// serveAgent answers one request, reporting whether to keep going. // serveAgent answers one request and returns its operation.
func serveAgent(c net.Conn, secret []byte) bool { func serveAgent(c net.Conn, secret []byte) byte {
c.SetDeadline(time.Now().Add(agentTimeout)) c.SetDeadline(time.Now().Add(agentTimeout))
op := make([]byte, 1) op := make([]byte, 1)
if _, err := io.ReadFull(c, op); err != nil { if _, err := io.ReadFull(c, op); err != nil {
return true return 0
} }
switch op[0] { switch op[0] {
case agentGet: case agentGet:
c.Write(secret) c.Write(secret)
return true
case agentQuit: case agentQuit:
clear(secret) clear(secret)
return false
} }
return true return op[0]
} }
// keyIdentity unlocks the key at keyPath: from the agent if one is running // keyIdentity unlocks the key at keyPath: from the agent if one is running
+28 -4
View File
@@ -186,6 +186,26 @@ Download the one file for your system, check its SHA-256, rename it to
Or build every platform yourself with \cmd{./build.sh}. Or build every platform yourself with \cmd{./build.sh}.
\end{tldr} \end{tldr}
\section{The easy way on Linux: my package repository}
On Debian, Ubuntu, Fedora, RHEL, openSUSE or Arch, add my signed repository
once and install the \cmd{dixon-vault} package with your usual package
manager. You get the manual pages too, and new releases arrive with your
normal system updates. On Debian or Ubuntu:
\begin{shell}
$ sudo install -d -m 0755 /etc/apt/keyrings
$ curl -fsSL https://repo.dixon.cx/dixon.gpg | sudo tee /etc/apt/keyrings/dixon.gpg >/dev/null
$ echo "deb [signed-by=/etc/apt/keyrings/dixon.gpg] https://repo.dixon.cx/apt stable main" |
sudo tee /etc/apt/sources.list.d/dixon.list
$ sudo apt update && sudo apt install dixon-vault
\end{shell}
The commands for dnf, zypper and pacman are at \file{dixon.cx/repo}. The
package is called \cmd{dixon-vault} because plain \cmd{vault} is HashiCorp
Vault's name; the command it installs is still \cmd{vault}. Everyone else,
read on.
\section{Pick your file} \section{Pick your file}
vault is a single self-contained program. No installer, no runtime, no vault is a single self-contained program. No installer, no runtime, no
@@ -714,13 +734,16 @@ vault.
\begin{shell} \begin{shell}
$ vault unlock # ask once, remember the key for 5 minutes $ vault unlock # ask once, remember the key for 5 minutes
$ vault unlock --for 30m # or for as long as you say $ vault unlock --for 30m # or for as long as you say
$ vault unlock --idle # 5 minutes since you last used it
$ vault lock # forget it right now $ vault lock # forget it right now
\end{shell} \end{shell}
After \cmd{unlock}, every command skips the passphrase prompt until the time After \cmd{unlock}, every command skips the passphrase prompt until the time
is up. It's a hard deadline, counted from when you unlocked, not from when you is up. Normally that's a hard deadline, counted from when you unlocked, so a
last used it, so a laptop left open still locks itself. Run \cmd{unlock} laptop left open still locks itself; run \cmd{unlock} again for a fresh
again to start a fresh countdown. countdown. If you'd rather it stayed unlocked while you're busy, \cmd{--idle}
counts from the last command that used the key instead, so it locks only
after that long without use.
\shot{unlock}{Unlock once, work without prompts, lock when you're done} \shot{unlock}{Unlock once, work without prompts, lock when you're done}
@@ -1362,7 +1385,7 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename or move a file or folder. Instant.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename or move a file or folder. Instant.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault cp [-r] OLD NEW}\\[2pt]\small Copy a file, or a folder with -r. Instant, no extra space.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault cp [-r] OLD NEW}\\[2pt]\small Copy a file, or a folder with -r. Instant, no extra space.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault rm [-r] [-f] NAME}\\[2pt]\small Delete a file, or a folder with -r. Frees the space. No undo.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault rm [-r] [-f] NAME}\\[2pt]\small Delete a file, or a folder with -r. Frees the space. No undo.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault unlock [--for 5m]}\\[2pt]\small Remember the key, so commands stop asking. Hard deadline.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault unlock [--for 5m] [--idle]}\\[2pt]\small Remember the key, so commands stop asking. Fixed deadline, or since last use.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault lock}\\[2pt]\small Forget the remembered key now.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault lock}\\[2pt]\small Forget the remembered key now.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault help}\\[2pt]\small Show the command summary.\end{tcolorbox}
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries man vault}\\[2pt]\small A manual page for vault and for every command.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries man vault}\\[2pt]\small A manual page for vault and for every command.\end{tcolorbox}
@@ -1380,6 +1403,7 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
--yes & \textrm{---} & ask, for \cmd{migrate} \\ --yes & \textrm{---} & ask, for \cmd{migrate} \\
-r & \textrm{---} & \cmd{rm}, \cmd{get}, \cmd{cp}: one file; \cmd{ls}: one level \\ -r & \textrm{---} & \cmd{rm}, \cmd{get}, \cmd{cp}: one file; \cmd{ls}: one level \\
--for DURATION & \textrm{---} & \cmd{5m}, for \cmd{unlock} \\ --for DURATION & \textrm{---} & \cmd{5m}, for \cmd{unlock} \\
--idle & \textrm{---} & \cmd{unlock}: count from the unlock, not the last use \\
-f & \textrm{---} & \cmd{rm}: ask before \cmd{-r}, fail if missing \\ -f & \textrm{---} & \cmd{rm}: ask before \cmd{-r}, fail if missing \\
\end{vtable} \end{vtable}
+8 -2
View File
@@ -5,6 +5,7 @@ vault-unlock, vault-lock \- remember the unlocked key for a while, or forget it
.B vault unlock .B vault unlock
.RB [ \-\-for .RB [ \-\-for
.IR DURATION ] .IR DURATION ]
.RB [ \-\-idle ]
.RB [ \-\-key .RB [ \-\-key
.IR PATH ] .IR PATH ]
.br .br
@@ -17,8 +18,9 @@ asks for the passphrase, checks it, and starts a helper process, much like
.BR ssh\-agent (1), .BR ssh\-agent (1),
that holds the unlocked key in memory until \fIDURATION\fR has passed that holds the unlocked key in memory until \fIDURATION\fR has passed
(default 5m). Until then every command using the same key file skips the (default 5m). Until then every command using the same key file skips the
passphrase prompt. The deadline is fixed when you unlock; using the key does passphrase prompt. By default the deadline is fixed when you unlock and
not extend it. Running using the key does not extend it; with \fB\-\-idle\fR it is \fIDURATION\fR
after the last command that used the key. Running
.B unlock .B unlock
again replaces the helper with a fresh one. again replaces the helper with a fresh one.
.PP .PP
@@ -38,6 +40,10 @@ handing the machine to anyone.
.TP .TP
.BI \-\-for " DURATION" .BI \-\-for " DURATION"
How long to remember the key, such as 90s, 5m or 2h. Default 5m. How long to remember the key, such as 90s, 5m or 2h. Default 5m.
.TP
.B \-\-idle
Count \fIDURATION\fR from the last time a command used the key, rather than
from the unlock, so the key stays while you keep working.
.PP .PP
See also \fB\-\-key\fR in See also \fB\-\-key\fR in
.BR vault (1). .BR vault (1).
+12 -6
View File
@@ -57,7 +57,8 @@ const usage = `Usage:
vault rm -r FOLDER [-f] delete a folder and everything under it; vault rm -r FOLDER [-f] delete a folder and everything under it;
asks first unless -f (or -rf) is given asks first unless -f (or -rf) is given
vault unlock [--for DURATION] remember the passphrase for DURATION vault unlock [--for DURATION] remember the passphrase for DURATION
(default 5m), so commands skip asking [--idle] (default 5m), so commands skip asking;
with --idle, DURATION since last used
vault lock forget it now vault lock forget it now
Options (any command): Options (any command):
@@ -91,8 +92,8 @@ func main() {
os.Exit(2) os.Exit(2)
} }
cmd, args := os.Args[1], os.Args[2:] cmd, args := os.Args[1], os.Args[2:]
if cmd == agentCmd && len(args) == 2 { if cmd == agentCmd && len(args) == 3 {
runAgent(args[0], args[1], os.Stdin, os.Stdout) runAgent(args[0], args[1], args[2], os.Stdin, os.Stdout)
return return
} }
fset := flag.NewFlagSet("vault "+cmd, flag.ContinueOnError) fset := flag.NewFlagSet("vault "+cmd, flag.ContinueOnError)
@@ -103,6 +104,7 @@ func main() {
filler := fset.String("filler", "0", "") filler := fset.String("filler", "0", "")
yes := fset.Bool("yes", false, "") yes := fset.Bool("yes", false, "")
ttl := fset.Duration("for", 5*time.Minute, "") ttl := fset.Duration("for", 5*time.Minute, "")
idle := fset.Bool("idle", false, "")
// rm's -r and -f; Go's flag package has no combined short flags, so // rm's -r and -f; Go's flag package has no combined short flags, so
// -rf and -fr are spelled out. // -rf and -fr are spelled out.
recurse, force := fset.Bool("r", false, ""), fset.Bool("f", false, "") recurse, force := fset.Bool("r", false, ""), fset.Bool("f", false, "")
@@ -295,10 +297,14 @@ func main() {
check(err) check(err)
id, err := unlockKeyFile(*keyPath, pass) id, err := unlockKeyFile(*keyPath, pass)
check(err) check(err)
check(agentStart(*keyPath, id, *ttl)) check(agentStart(*keyPath, id, *ttl, *idle))
id.Close() id.Close()
fmt.Fprintf(os.Stderr, "unlocked until %s; vault lock forgets the key sooner\n", if *idle {
time.Now().Add(*ttl).Format("15:04:05")) fmt.Fprintf(os.Stderr, "unlocked until unused for %s; vault lock forgets the key sooner\n", *ttl)
} else {
fmt.Fprintf(os.Stderr, "unlocked until %s; vault lock forgets the key sooner\n",
time.Now().Add(*ttl).Format("15:04:05"))
}
case "lock": case "lock":
want(0, 0) want(0, 0)
if agentStop(*keyPath) { if agentStop(*keyPath) {
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# Build Linux packages (.deb, .rpm, Arch .pkg.tar.zst) for a release tag and
# publish them to the signed apt, dnf and pacman repositories served at
# https://repo.dixon.cx.
#
# packaging/repo.sh # the newest tag
# packaging/repo.sh 0.5 # a given tag
#
# Builds from a clean export of the tag, so the working tree doesn't matter.
# 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.
# 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)
#
# 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" .
}
# ----------------------------------------------------------------------------
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]
say() { echo "repo: $*" >&2; }
die() { say "$*"; exit 1; }
for tool in 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"
export GNUPGHOME=$REPO_GNUPGHOME
KEY=$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^fpr/{print $10; exit}')
[[ -n $KEY ]] || die "no signing key in $GNUPGHOME"
cd "$(git rev-parse --show-toplevel)"
TAG=${1:-$(git describe --tags --abbrev=0)}
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || die "no tag $TAG"
VERSION=${TAG#v}
export VERSION
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
git archive "$TAG" | tar -x -C "$WORK" --one-top-level=src
gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc"
# ---- 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
# ---- publish ---------------------------------------------------------------
# One publisher at a time: the indexes cover every project's packages.
exec 9>"$REPO_DIR/../.publish.lock"
flock 9
say "publishing to $REPO_DIR"
gpg --batch --armor --export "$KEY" >"$REPO_DIR/dixon.asc"
gpg --batch --export "$KEY" >"$REPO_DIR/dixon.gpg"
sign() { gpg --batch --yes --local-user "$KEY" "$@"; }
# apt: pool/main/<letter>/<name>/, indexed by apt-ftparchive.
pool=$REPO_DIR/apt/pool/$COMPONENT/${PACKAGE:0:1}/$PACKAGE
mkdir -p "$pool"
cp "$WORK"/out/*.deb "$pool/"
(
cd "$REPO_DIR/apt"
# Built beside the live suite and swapped in, so clients never see half.
dists=dists/.$SUITE.new
rm -rf "$dists"
debarchs=$(find pool -name '*.deb' | sed -E 's/.*_([^_]+)\.deb$/\1/' | sort -u | tr '\n' ' ')
for a in $debarchs; do
d=$dists/$COMPONENT/binary-$a
mkdir -p "$d"
apt-ftparchive --arch "$a" packages pool >"$d/Packages"
gzip -9kn "$d/Packages"
done
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=repo.dixon.cx \
-o APT::FTPArchive::Release::Label=repo.dixon.cx \
-o APT::FTPArchive::Release::Suite=$SUITE \
-o APT::FTPArchive::Release::Codename=$SUITE \
-o APT::FTPArchive::Release::Components=$COMPONENT \
-o "APT::FTPArchive::Release::Architectures=${debarchs% }" \
release "$dists" >"$WORK/Release"
mv "$WORK/Release" "$dists/Release"
sign --clearsign -o "$dists/InRelease" "$dists/Release"
sign --armor --detach-sign -o "$dists/Release.gpg" "$dists/Release"
rm -rf "dists/.$SUITE.old"
[[ -d dists/$SUITE ]] && mv "dists/$SUITE" "dists/.$SUITE.old"
mv "$dists" "dists/$SUITE"
rm -rf "dists/.$SUITE.old"
)
# dnf: every architecture in one directory; dnf picks its own.
mkdir -p "$REPO_DIR/rpm"
cp "$WORK"/out/*.rpm "$REPO_DIR/rpm/"
docker image inspect repo-tools:fedora >/dev/null 2>&1 ||
docker build -q -t repo-tools:fedora - >/dev/null <<-'EOF'
FROM fedora:42
RUN dnf -y install createrepo_c && dnf clean all
EOF
docker run --rm --user "$(id -u):$(id -g)" -v "$REPO_DIR/rpm:/repo" repo-tools:fedora \
createrepo_c --quiet --update --general-compress-type=gz /repo
sign --armor --detach-sign -o "$REPO_DIR/rpm/repodata/repomd.xml.asc" "$REPO_DIR/rpm/repodata/repomd.xml"
cat >"$REPO_DIR/rpm/dixon.repo" <<EOF
[dixon]
name=repo.dixon.cx
baseurl=https://repo.dixon.cx/rpm
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.dixon.cx/dixon.asc
EOF
# pacman: one directory per architecture, each package signed, then
# repo-add (from an Arch container) and a signed database.
added=
for pkg in "$WORK"/out/*.pkg.tar.zst; do
a=$(basename "$pkg" .pkg.tar.zst)
a=${a##*-}
mkdir -p "$REPO_DIR/arch/$a"
cp "$pkg" "$REPO_DIR/arch/$a/"
sign --detach-sign -o "$REPO_DIR/arch/$a/$(basename "$pkg").sig" "$pkg"
added+=" $a"
done
for a in $(tr ' ' '\n' <<<"$added" | sort -u); do
dir=$REPO_DIR/arch/$a
# A fresh database of every package here; -p keeps the newest version.
rm -f "$dir/$ARCH_REPO".{db,files}{,.tar.gz}{,.sig}
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$dir:/repo" -w /repo archlinux:latest \
sh -c "repo-add --quiet --prevent-downgrade $ARCH_REPO.db.tar.gz *.pkg.tar.zst" 2>&1 |
{ grep -v "newer version\|^$" >&2 || true; }
for db in $ARCH_REPO.db $ARCH_REPO.files; do
sign --detach-sign -o "$dir/$db.tar.gz.sig" "$dir/$db.tar.gz"
ln -sfn "$db.tar.gz.sig" "$dir/$db.sig"
done
rm -f "$dir"/*.old "$dir"/*.old.sig
done
say "published $PACKAGE $VERSION: $(cd "$WORK/out" && ls | wc -l) packages"
+37 -1
View File
@@ -21,6 +21,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/andydixon/vault/internal/pqcrypt"
) )
func TestKeyFile(t *testing.T) { func TestKeyFile(t *testing.T) {
@@ -668,7 +670,7 @@ func TestAgent(t *testing.T) {
secret = append(secret, id.Seed()...) secret = append(secret, id.Seed()...)
pr, pw := io.Pipe() pr, pw := io.Pipe()
stopped := make(chan struct{}) stopped := make(chan struct{})
go func() { runAgent(sock, "1m", bytes.NewReader(secret), pw); close(stopped) }() go func() { runAgent(sock, "1m", "fixed", bytes.NewReader(secret), pw); close(stopped) }()
if msg, _ := io.ReadAll(pr); string(msg) != "ok" { if msg, _ := io.ReadAll(pr); string(msg) != "ok" {
t.Fatalf("agent said %q", msg) t.Fatalf("agent said %q", msg)
} }
@@ -690,3 +692,37 @@ func TestAgent(t *testing.T) {
t.Fatal("agent still running") t.Fatal("agent still running")
} }
} }
func TestAgentIdle(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", dir)
keyPath := filepath.Join(dir, "key")
sock, err := agentSocket(keyPath)
if err != nil {
t.Fatal(err)
}
id, err := pqcrypt.GeneratePQIdentity()
if err != nil {
t.Fatal(err)
}
secret := binary.BigEndian.AppendUint16(nil, id.SuiteID())
secret = append(secret, id.Seed()...)
pr, pw := io.Pipe()
stopped := make(chan struct{})
go func() { runAgent(sock, "400ms", "idle", bytes.NewReader(secret), pw); close(stopped) }()
if msg, _ := io.ReadAll(pr); string(msg) != "ok" {
t.Fatalf("agent said %q", msg)
}
// Used every 150ms, it outlives its 400ms by well over twice.
for range 7 {
time.Sleep(150 * time.Millisecond)
if _, err := agentIdentity(keyPath); err != nil {
t.Fatal("idle agent expired while in use: ", err)
}
}
select {
case <-stopped:
case <-time.After(2 * time.Second):
t.Fatal("idle agent outlived its idle time")
}
}