diff --git a/CLAUDE.md b/CLAUDE.md index d723e5f..0357291 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,13 @@ root that builds a single `vault` binary. Module `github.com/andydixon/vault`. - `main.go` - CLI: `genkey`, `init`, `add` (file, directory or URL), `migrate` (add, then delete originals), `mirror`, `absorb` (tar on stdin, `absorb.go`), `ls` (one folder level), `list` (recursive), - `get`, `mv`, `rm` (`-r` for a folder, asks unless `-f`). + `get` (`-r` for a folder), `mv`/`cp` (files or folders; `transfers` + resolves names like mv(1), copies share objects), `rm` (`-r` for a + folder, asks unless `-f`), `unlock`/`lock`. +- `agent*.go` - `vault unlock` re-executes itself as a hidden `__agent` + 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 + deadline. `keyIdentity` asks it first unless `$VAULT_PASSPHRASE` is set. - `keyfile.go` - key file with two passphrase slots (Argon2id + 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. @@ -49,7 +55,7 @@ export VAULT_PASSPHRASE=test VAULT_KEY=$PWD/k VAULT_DIR=$PWD/s ## Invariants -- Never write a seed or private key unencrypted. +- Never write a seed or private key unencrypted (the agent gets it by pipe). - Keep suite IDs, stanza tags, HKDF info strings and `fileKeySize` (32) stable. - Every ciphertext's and shard directory's mtime is pinned to the epoch; records stay one fixed size; objects stay at most 4 MiB and bucket-padded. diff --git a/README.md b/README.md index d1cfd28..b4ded5a 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,16 @@ vault list # every file, all the way down, full names vault list work # everything under work/ vault get work/2026/report.pdf # writes ./report.pdf vault get report.pdf -o copy.pdf +vault get -r work # a whole folder, into ./work vault mv report.pdf old/report.pdf +vault mv report.pdf old/ # the same: into a folder, like mv(1) +vault mv work archive/work # folders move too +vault cp -r work work-backup # instant; copies share their encrypted data vault rm old/report.pdf vault rm -r old # a whole folder; lists it and asks first vault rm -rf old # the same, without asking +vault unlock # remember the key for 5 minutes (--for 30m) +vault lock # forget it now ``` Commands that change the vault take an exclusive lock on `DIR/.lock`, and @@ -77,6 +83,16 @@ existing file and writes nothing until the whole object has authenticated. The passphrase is read from the terminal, or from `$VAULT_PASSPHRASE` for scripts. +`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 +default) or `vault lock`. Until then commands skip the prompt, unless +`$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 +against swapping, it cannot be core-dumped (or, on Linux, debugged), and it +answers only on a socket in a directory private to your user. While it is +unlocked, anything running as you can use the vault, decoy or not, so lock +before handing the machine over. + ## Decoy passphrase `vault genkey` offers an optional second passphrase. In scripts, set diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..0f10406 --- /dev/null +++ b/agent.go @@ -0,0 +1,231 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/andydixon/vault/internal/pqcrypt" + "github.com/andydixon/vault/internal/term" +) + +// The agent remembers an unlocked key for a while, like ssh-agent, so +// commands skip the passphrase. `vault unlock` checks the passphrase, then +// starts a copy of itself (agentCmd) and hands it the suite ID and master +// 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, +// 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. + +const ( + agentCmd = "__agent" // hidden: the agent process itself + agentGet = 'g' + agentQuit = 'q' + agentReply = 2 + seedSize + agentTimeout = 2 * time.Second +) + +// agentSocket is the socket of the agent for the key file at keyPath, one +// per key file, in a private per-user directory it creates if needed. +func agentSocket(keyPath string) (string, error) { + abs, err := filepath.Abs(keyPath) + if err != nil { + return "", err + } + base := os.Getenv("XDG_RUNTIME_DIR") + if base == "" { + base = os.TempDir() + } + dir := filepath.Join(base, fmt.Sprintf("vault-%d", os.Getuid())) + if err := os.Mkdir(dir, 0o700); err != nil && !os.IsExist(err) { + return "", err + } + if err := privateDir(dir); err != nil { + return "", err + } + sum := sha256.Sum256([]byte(abs)) + return filepath.Join(dir, "agent-"+hex.EncodeToString(sum[:8])), nil +} + +// agentDial connects to the agent for keyPath and sends it op. +func agentDial(keyPath string, op byte) (net.Conn, error) { + sock, err := agentSocket(keyPath) + if err != nil { + return nil, err + } + c, err := net.DialTimeout("unix", sock, agentTimeout) + if err != nil { + return nil, err + } + c.SetDeadline(time.Now().Add(agentTimeout)) + if _, err := c.Write([]byte{op}); err != nil { + c.Close() + return nil, err + } + return c, nil +} + +// agentIdentity asks a running agent for the key at keyPath. +func agentIdentity(keyPath string) (*pqcrypt.PQIdentity, error) { + c, err := agentDial(keyPath, agentGet) + if err != nil { + return nil, err + } + defer c.Close() + buf := make([]byte, agentReply) + defer clear(buf) + if _, err := io.ReadFull(c, buf); err != nil { + return nil, err + } + return pqcrypt.NewPQIdentityFromSeed(binary.BigEndian.Uint16(buf), buf[2:]) +} + +// agentStop tells the agent for keyPath to forget its key, reporting +// whether one was running. +func agentStop(keyPath string) bool { + c, err := agentDial(keyPath, agentQuit) + if err != nil { + return false + } + defer c.Close() + io.Copy(io.Discard, c) // returns once the agent has wiped the key + return true +} + +// agentStart replaces any agent for keyPath with one holding id until ttl +// has passed. +func agentStart(keyPath string, id *pqcrypt.PQIdentity, ttl time.Duration) error { + sock, err := agentSocket(keyPath) + if err != nil { + return err + } + agentStop(keyPath) + self, err := os.Executable() + if err != nil { + return err + } + cmd := exec.Command(self, agentCmd, sock, ttl.String()) + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + secret := binary.BigEndian.AppendUint16(make([]byte, 0, agentReply), id.SuiteID()) + seed := id.Seed() + secret = append(secret, seed...) + clear(seed) + _, err = stdin.Write(secret) + clear(secret) + stdin.Close() + // The agent closes its standard output once listening, after saying + // "ok" or why not. + msg, _ := io.ReadAll(stdout) + if err != nil || string(msg) != "ok" { + cmd.Process.Kill() + cmd.Wait() + if err == nil { + err = fmt.Errorf("agent did not start: %s", strings.TrimSpace(string(msg))) + } + return err + } + return cmd.Process.Release() +} + +// 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 +// passed or it is told to quit. +func runAgent(sock, ttlArg string, in io.Reader, out io.WriteCloser) { + report := func(msg string) { io.WriteString(out, msg); out.Close() } + ttl, err := time.ParseDuration(ttlArg) + if err != nil || ttl <= 0 { + report("bad duration") + return + } + secret := make([]byte, agentReply) + protectMemory(secret) + if _, err := io.ReadFull(in, secret); err != nil { + report("no key received") + return + } + // A socket left by an agent that died stops Listen; agentStart has + // already stopped any live one. + os.Remove(sock) + l, err := net.Listen("unix", sock) + if err != nil { + clear(secret) + report(err.Error()) + return + } + os.Chmod(sock, 0o600) + report("ok") + done := make(chan struct{}) + time.AfterFunc(ttl, func() { l.Close() }) + go func() { + defer close(done) + for { + c, err := l.Accept() + if err != nil { + return + } + if !serveAgent(c, secret) { + l.Close() + c.Close() + return + } + c.Close() + } + }() + <-done + clear(secret) + os.Remove(sock) +} + +// serveAgent answers one request, reporting whether to keep going. +func serveAgent(c net.Conn, secret []byte) bool { + c.SetDeadline(time.Now().Add(agentTimeout)) + op := make([]byte, 1) + if _, err := io.ReadFull(c, op); err != nil { + return true + } + switch op[0] { + case agentGet: + c.Write(secret) + return true + case agentQuit: + clear(secret) + return false + } + return true +} + +// keyIdentity unlocks the key at keyPath: from the agent if one is running +// and no passphrase is in the environment, else with the passphrase. +func keyIdentity(keyPath string) (*pqcrypt.PQIdentity, error) { + if _, ok := os.LookupEnv(passphraseEnv); !ok { + if id, err := agentIdentity(keyPath); err == nil { + return id, nil + } + } + pass, err := term.ReadPassphrase("Enter passphrase:", passphraseEnv) + if err != nil { + return nil, err + } + return unlockKeyFile(keyPath, pass) +} diff --git a/agent_linux.go b/agent_linux.go new file mode 100644 index 0000000..45a980e --- /dev/null +++ b/agent_linux.go @@ -0,0 +1,11 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import "golang.org/x/sys/unix" + +// Not dumpable also means not ptrace-able by the same user. +func init() { + undumpable = func() { unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) } +} diff --git a/agent_other.go b/agent_other.go new file mode 100644 index 0000000..3ea0017 --- /dev/null +++ b/agent_other.go @@ -0,0 +1,13 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build !unix + +package main + +// ponytail: no memory locking off unix; VirtualLock on Windows if wanted. +func protectMemory([]byte) {} + +// privateDir trusts the per-user temporary directory; Windows keeps it +// private with its ACL. +func privateDir(string) error { return nil } diff --git a/agent_unix.go b/agent_unix.go new file mode 100644 index 0000000..584eb58 --- /dev/null +++ b/agent_unix.go @@ -0,0 +1,39 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build unix + +package main + +import ( + "fmt" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +// undumpable stops other processes of this user reading the agent's memory, +// where the platform can. +var undumpable = func() {} + +// protectMemory keeps b out of swap and the agent out of core dumps. +func protectMemory(b []byte) { + unix.Mlock(b) + unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{}) + undumpable() +} + +// privateDir checks dir is a real directory that only this user can use, +// so nobody else can reach, swap or plant the agent's socket. +func privateDir(dir string) error { + fi, err := os.Lstat(dir) + if err != nil { + return err + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !fi.IsDir() || !ok || int(st.Uid) != os.Getuid() || fi.Mode().Perm() != 0o700 { + return fmt.Errorf("%s is not a private directory of this user", dir) + } + return nil +} diff --git a/docs/guide/img/absorb.png b/docs/guide/img/absorb.png index 08b5403..aae4945 100644 Binary files a/docs/guide/img/absorb.png and b/docs/guide/img/absorb.png differ diff --git a/docs/guide/img/build.png b/docs/guide/img/build.png index 472cd78..1686d99 100644 Binary files a/docs/guide/img/build.png and b/docs/guide/img/build.png differ diff --git a/docs/guide/img/cp-mv.png b/docs/guide/img/cp-mv.png new file mode 100644 index 0000000..c2f7c29 Binary files /dev/null and b/docs/guide/img/cp-mv.png differ diff --git a/docs/guide/img/decoy-hidden.png b/docs/guide/img/decoy-hidden.png index 6e204a5..1c72641 100644 Binary files a/docs/guide/img/decoy-hidden.png and b/docs/guide/img/decoy-hidden.png differ diff --git a/docs/guide/img/decoy-views.png b/docs/guide/img/decoy-views.png index 2694dd1..ecf1f2d 100644 Binary files a/docs/guide/img/decoy-views.png and b/docs/guide/img/decoy-views.png differ diff --git a/docs/guide/img/genkey.png b/docs/guide/img/genkey.png index 3a9d5e0..db8ff3d 100644 Binary files a/docs/guide/img/genkey.png and b/docs/guide/img/genkey.png differ diff --git a/docs/guide/img/help.png b/docs/guide/img/help.png index 278a23b..14df0d9 100644 Binary files a/docs/guide/img/help.png and b/docs/guide/img/help.png differ diff --git a/docs/guide/img/init.png b/docs/guide/img/init.png index 37d1838..7028357 100644 Binary files a/docs/guide/img/init.png and b/docs/guide/img/init.png differ diff --git a/docs/guide/img/list.png b/docs/guide/img/list.png index 95a3e0b..5efac4f 100644 Binary files a/docs/guide/img/list.png and b/docs/guide/img/list.png differ diff --git a/docs/guide/img/mv-rm.png b/docs/guide/img/mv-rm.png index dedae05..bdb7492 100644 Binary files a/docs/guide/img/mv-rm.png and b/docs/guide/img/mv-rm.png differ diff --git a/docs/guide/img/on-disk.png b/docs/guide/img/on-disk.png index 3b921a4..65777bc 100644 Binary files a/docs/guide/img/on-disk.png and b/docs/guide/img/on-disk.png differ diff --git a/docs/guide/img/rm-r.png b/docs/guide/img/rm-r.png index 132b6fe..2bf43c8 100644 Binary files a/docs/guide/img/rm-r.png and b/docs/guide/img/rm-r.png differ diff --git a/docs/guide/img/scripting.png b/docs/guide/img/scripting.png index 1596fc9..7f0c204 100644 Binary files a/docs/guide/img/scripting.png and b/docs/guide/img/scripting.png differ diff --git a/docs/guide/img/unlock.png b/docs/guide/img/unlock.png new file mode 100644 index 0000000..fd75a2b Binary files /dev/null and b/docs/guide/img/unlock.png differ diff --git a/docs/guide/shots.py b/docs/guide/shots.py index 81eba78..3af9457 100755 --- a/docs/guide/shots.py +++ b/docs/guide/shots.py @@ -387,6 +387,23 @@ def main(): t.cmd("vault get nope 2>/dev/null; echo \"exit code: $?\"", P) shoot("scripting", t) + # cp, folder mv, get -r + t = Term(box) + t.cmd("vault cp -r letters archive/2025", P) + t.cmd("vault mv ideas/notes.md archive/", P) + t.cmd("vault ls archive", P) + t.cmd("vault get -r archive -o restored/archive", P) + shoot("cp-mv", t) + + # unlock: one passphrase, then commands stop asking until lock + t = Term(box) + t.cmd("vault unlock", P) + t.cmd("vault ls") + t.cmd("vault get archive/notes.md -o /tmp/notes.md") + t.cmd("vault lock") + t.moment("vault ls", "Enter passphrase:") + shoot("unlock", t) + # building, into a temporary OUT so an existing dist/ is left alone rel = tempfile.mkdtemp(prefix="vault-release-") targets = "linux/amd64 linux/arm darwin/arm64 windows/amd64" diff --git a/docs/guide/vault-guide.tex b/docs/guide/vault-guide.tex index ee9ee76..b1abae2 100644 --- a/docs/guide/vault-guide.tex +++ b/docs/guide/vault-guide.tex @@ -511,13 +511,14 @@ type \cmd{--vault} again. % ====================================================================== \chapter{Everyday moves} -\tagline{add, ls, get, mv, rm: the five commands you'll actually use} +\tagline{add, ls, get, mv, cp, rm: the commands you'll actually use} \begin{tldr} \cmd{add} puts files in (optionally into a folder), \cmd{ls} shows them a -folder at a time, \cmd{get} takes one back out, \cmd{mv} renames, \cmd{rm} -deletes (a whole folder with \cmd{-r}). Each asks -for your passphrase. That's the whole daily workflow. +folder at a time, \cmd{get} takes them back out, \cmd{mv} and \cmd{cp} move +and copy, \cmd{rm} deletes (folders with \cmd{-r} throughout). Each asks for +your passphrase, unless you've run \cmd{unlock}. That's the whole daily +workflow. \end{tldr} \section{Putting things in: \texttt{add}} @@ -643,6 +644,7 @@ than a thief can. \begin{shell} $ vault get NAME # writes the file here, under its own name $ vault get NAME -o OUTPUT # writes it wherever you say +$ vault get -r FOLDER # a whole folder, into ./FOLDER \end{shell} \shot{get}{Getting files back, including vault refusing to overwrite} @@ -660,16 +662,23 @@ Three safety habits are built in, so you don't need to think about them: and modification time. \end{checks} -\section{Renaming and moving: \texttt{mv}} +\section{Renaming, moving and copying: \texttt{mv} and \texttt{cp}} \begin{shell} -$ vault mv OLD NEW +$ vault mv OLD NEW # rename a file or a whole folder +$ vault mv notes.md ideas/ # move into a folder, keeping the name +$ vault cp OLD NEW # copy a file +$ vault cp -r OLD NEW # copy a folder \end{shell} -\cmd{NEW} is the \emph{full} new name, folder included. So to move -\file{notes.md} into \file{ideas}, it's \cmd{vault mv notes.md -ideas/notes.md}, not \cmd{\ldots\ ideas/}. It won't overwrite an existing name, -and it doesn't re-encrypt the file, so it's instant however big the file is. +They work like the everyday \cmd{mv} and \cmd{cp}: if \cmd{NEW} ends in +\cmd{/} or is a folder already, \cmd{OLD} goes inside it. Neither will +overwrite an existing name, and neither re-encrypts anything, so both are +instant however big the files are. A copy shares the original's encrypted +data, so it takes no extra space, and deleting one leaves the other intact. +\cmd{get -r} brings a whole folder back out, keeping its shape. + +\shot{cp-mv}{A folder copied, a file moved into it, and the lot brought back out} \section{Deleting: \texttt{rm}} @@ -700,6 +709,47 @@ vault. \shot{rm-r}{A folder deleted: vault lists it, asks, then frees the space} +\section{Not typing your passphrase every time: \texttt{unlock}} + +\begin{shell} +$ vault unlock # ask once, remember the key for 5 minutes +$ vault unlock --for 30m # or for as long as you say +$ vault lock # forget it right now +\end{shell} + +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 +last used it, so a laptop left open still locks itself. Run \cmd{unlock} +again to start a fresh countdown. + +\shot{unlock}{Unlock once, work without prompts, lock when you're done} + +\subsection{How it keeps the key safe} +\cmd{unlock} starts a small helper process, much like \cmd{ssh-agent}, that +holds your unlocked key in memory and nowhere else: + +\begin{checks} + \item \textbf{Never on disk.} The key goes from \cmd{unlock} to the helper + through a pipe. Its memory is locked so it can't be swapped out, and + on Linux the helper can't be debugged or core-dumped, even by you. + \item \textbf{Only you can reach it.} It answers on a socket inside a folder + only your user account can open (in \file{\$XDG\_RUNTIME\_DIR}, or + your temporary folder). vault checks that folder's owner and + permissions every time. + \item \textbf{It wipes itself.} At the deadline, or on \cmd{vault lock}, the + key is overwritten and the helper exits. Rebooting ends it too. + \item \textbf{An explicit passphrase wins.} If \env{VAULT\_PASSPHRASE} is + set, vault uses that and ignores the helper, so scripts behave + exactly as before. +\end{checks} + +\begin{headsup} +\textbf{While it's unlocked, anyone at your keyboard is you.} Anything +running as your user can use the key without a passphrase. Lock before you +walk away, and before handing your machine to anyone, decoy or not (see +\emph{The honest limits} in the next chapter). +\end{headsup} + \section{Flags work anywhere} Every command understands \cmd{--key PATH} and \cmd{--vault DIR}, and they can @@ -716,7 +766,8 @@ $ vault add report.pdf --vault=~/work \item \cmd{add FILE [FOLDER]}: keeps the file's name, optionally in a folder. Folders add recursively. \item Adding an existing name replaces it, safely. \item \cmd{ls [FOLDER]} one level at a time, \cmd{list [FOLDER]} everything. - \item \cmd{get NAME [-o OUT]}, \cmd{mv OLD NEW}, \cmd{rm NAME}, \cmd{rm -r FOLDER}. + \item \cmd{get [-r] NAME [-o OUT]}, \cmd{mv OLD NEW}, \cmd{cp [-r] OLD NEW}, \cmd{rm NAME}, \cmd{rm -r FOLDER}. + \item \cmd{unlock} remembers the key for 5 minutes; \cmd{lock} forgets it. \item \cmd{get} never overwrites and never publishes a half-checked file. \item \cmd{rm} has no undo. \end{pocketlist} @@ -857,6 +908,9 @@ something was. \begin{nopes} \item It won't help if someone saw you type the real passphrase, or has malware on your machine. + \item It won't help if the real key is still unlocked. A command run + while \cmd{vault unlock} holds it uses the real key, whatever + passphrase you're asked to give. Run \cmd{vault lock} first. \item It doesn't stop anyone deleting the raw files. It hides; it doesn't guard. \item It isn't legal advice. Some places treat misleading an official as an offence in its own right. Know where you are. @@ -886,7 +940,7 @@ it worked. \begin{vtable}{>{\leavevmode\ttfamily\small\color{vvioletdeep}}p{50mm}Y} \thead{VARIABLE} & \thead{WHAT IT DOES} \\ \midrule -VAULT\_PASSPHRASE & The passphrase. Skips the prompt. With \cmd{genkey}, it's the new key's passphrase. \\ +VAULT\_PASSPHRASE & The passphrase. Skips the prompt, and any key held by \cmd{vault unlock}. With \cmd{genkey}, it's the new key's passphrase. \\ VAULT\_DECOY\_PASSPHRASE & \cmd{genkey} only: the decoy passphrase. If \env{VAULT\_PASSPHRASE} is set and this isn't, no decoy is made. \\ VAULT\_KEY & The key file. Default \file{\tld/.vault/key}. Same as \cmd{--key}. \\ VAULT\_DIR & The vault folder. Default \file{\tld/.vault/store}. Same as \cmd{--vault}. \\ @@ -1225,18 +1279,29 @@ open \ldots/key: no such file or directory & No key there yet. Run \cmd{vault ge \ldots\ is not a vault; run vault init & Wrong \cmd{--vault} or \env{VAULT\_DIR}, or you haven't run \cmd{init} yet. \\ \ldots\ is already a vault & You ran \cmd{init} on an existing vault. Nothing was changed. \\ NAME: not in the vault & Check the exact name with \cmd{vault list}: it's case-sensitive and includes the folder. Or you typed the other passphrase. \\ -\ldots\ already exists & \cmd{get}: the output file exists, so use \cmd{-o}. \cmd{mv}: the new name is taken. \cmd{genkey}: you already have a key. \\ +\ldots\ already exists & \cmd{get}: the output file exists, so use \cmd{-o}. \cmd{mv}, \cmd{cp}: the new name is taken. \cmd{genkey}: you already have a key. \\ invalid vault path "\ldots" & Names can't start with \cmd{/} or \cmd{../}, end in \cmd{/}, or contain backslashes or control characters. \\ \ldots: not a regular file of at most 1\,TiB & You tried to add a device, a socket or a gigantic file. \\ \ldots\ changed while being added & The file was being written while vault read it. Try again once it's finished. \\ +\ldots\ is a folder; use -r & \cmd{cp} copies a folder only with \cmd{-r}. \\ +cannot put \ldots\ inside itself & You tried to move or copy a folder into itself. \\ refusing to remove the whole vault & \cmd{rm -r /} (or an empty folder name). Name the folder you mean. \\ rm cancelled; nothing changed & You didn't answer \texttt{y} to \cmd{rm -r}, or there was no terminal to ask on. \cmd{-f} skips the question. \\ skipping \ldots: not a regular file & Adding a folder: a symlink or special file was skipped. Everything else went in. \\ +\end{vtable} + +\subsection{Passphrases, the unlock helper, and damage} + +\begin{vtable}{>{\ttfamily\footnotesize}p{58mm}Y} +\thead{MESSAGE} & \thead{WHAT IT MEANS, AND THE FIX} \\ \midrule passphrases do not match & The two entries differed. Nothing was created; run it again. \\ passphrase must not be empty & Blank passphrases aren't allowed, for good reason. \\ decoy passphrase must differ from the passphrase & The decoy can't be the same as the real one. \\ VAULT\_PASSPHRASE is set but empty & Unset it, or give it a value. \\ could not read passphrase (set VAULT\_PASSPHRASE\ldots) & No terminal to ask on, typically cron or CI. Set the variable. \\ +\ldots\ is not a private directory of this user & The folder for \cmd{unlock}'s socket belongs to someone else or is open to others. Someone may be up to something; check who made it. Commands still work, asking for the passphrase. \\ +agent did not start: \ldots & \cmd{unlock} couldn't start its helper. The reason follows; commands still work, asking for the passphrase. \\ +nothing was unlocked & \cmd{vault lock} found nothing to lock. Not an error. \\ waiting for another vault process to finish & Not an error. Another command is writing; this one continues when it's done. \\ vault still locked after a minute\ldots & Plan~9 and WebAssembly only. If nothing else is running, delete the file it names. \\ skipping unreadable record \ldots & A catalogue entry is damaged. Everything else still works; restore \file{records/} from backup to recover that file. \\ @@ -1293,9 +1358,12 @@ passphrase. Nothing in this chapter helps with that. Back up your key. \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault absorb [FOLDER] < TAR}\\[2pt]\small Every file in a tar or .tar.gz from standard input.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault ls [FOLDER]}\\[2pt]\small One folder: sub-folders (ending in /) and files.\end{tcolorbox} \begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Every file under it, full names. Pipes cleanly.\end{tcolorbox} -\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox} -\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault mv OLD NEW}\\[2pt]\small Rename, using the full new name. Instant.\end{tcolorbox} +\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get [-r] NAME [-o OUT]}\\[2pt]\small Decrypt out, or a folder with -r. Never overwrites. Verifies first.\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 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 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 man vault}\\[2pt]\small A manual page for vault and for every command.\end{tcolorbox} \end{tcbraster} @@ -1310,7 +1378,8 @@ passphrase. Nothing in this chapter helps with that. Back up your key. -o OUT & \textrm{---} & the file's own name, here \\ --filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\ --yes & \textrm{---} & ask, for \cmd{migrate} \\ --r & \textrm{---} & \cmd{rm}: 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} \\ -f & \textrm{---} & \cmd{rm}: ask before \cmd{-r}, fail if missing \\ \end{vtable} diff --git a/docs/vault-cp.1 b/docs/vault-cp.1 new file mode 100644 index 0000000..1fc3382 --- /dev/null +++ b/docs/vault-cp.1 @@ -0,0 +1,37 @@ +.TH VAULT-CP 1 "September 2026" "vault" "User Commands" +.SH NAME +vault-cp \- copy a stored file or folder +.SH SYNOPSIS +.B vault cp +.RB [ \-r ] +.RB [ \-\-key +.IR PATH ] +.RB [ \-\-vault +.IR DIR ] +.I OLD NEW +.SH DESCRIPTION +Copies \fIOLD\fR to \fINEW\fR, following the same naming rules as +.BR vault\-mv (1). +A folder needs \fB\-r\fR. The copy shares the original's encrypted +objects rather than re-encrypting them, so it is instant and takes no +extra space; deleting either name leaves the other intact. +.SH OPTIONS +.TP +.B \-r +Copy a folder and everything under it. +.TP +.BI \-\-key " PATH" +Key file. Default: $VAULT_KEY, else ~/.vault/key. +.TP +.BI \-\-vault " DIR" +Vault directory. Default: $VAULT_DIR, else ~/.vault/store. +.SH ENVIRONMENT +.TP +.B VAULT_PASSPHRASE +Passphrase, instead of prompting on the terminal. +.SH SEE ALSO +.BR vault (1), +.BR vault\-mv (1), +.BR vault\-rm (1) +.SH AUTHOR +Andy Dixon , https://www.dixon.cx diff --git a/docs/vault-get.1 b/docs/vault-get.1 index cd27c1a..9fd4068 100644 --- a/docs/vault-get.1 +++ b/docs/vault-get.1 @@ -3,6 +3,7 @@ vault-get \- decrypt a stored file .SH SYNOPSIS .B vault get +.RB [ \-r ] .RB [ \-o .IR OUTPUT ] .RB [ \-\-key @@ -14,10 +15,19 @@ vault-get \- decrypt a stored file Decrypts \fINAME\fR. Nothing appears at the output path until every object has authenticated. The stored mode and modification time are restored. It refuses to overwrite an existing file. Takes a shared lock. +.PP +With \fB\-r\fR, \fINAME\fR is a folder, and every file under it is +decrypted into the output directory, keeping the folder structure. Each file +is checked as above; a clash stops the run, leaving the files already +written. .SH OPTIONS .TP .BI \-o " OUTPUT" -Output path. Default: the base name of \fINAME\fR in the current directory. +Output path, or with \fB\-r\fR output directory. Default: the base name of +\fINAME\fR in the current directory. +.TP +.B \-r +Decrypt a folder and everything under it. .PP See also \fB\-\-key\fR and \fB\-\-vault\fR in .BR vault (1). diff --git a/docs/vault-lock.1 b/docs/vault-lock.1 new file mode 100644 index 0000000..4c0b75c --- /dev/null +++ b/docs/vault-lock.1 @@ -0,0 +1 @@ +.so man1/vault-unlock.1 diff --git a/docs/vault-mv.1 b/docs/vault-mv.1 index ed43c22..dbca1bf 100644 --- a/docs/vault-mv.1 +++ b/docs/vault-mv.1 @@ -1,6 +1,6 @@ .TH VAULT-MV 1 "September 2026" "vault" "User Commands" .SH NAME -vault-mv \- rename a stored file +vault-mv \- rename or move a stored file or folder .SH SYNOPSIS .B vault mv .RB [ \-\-key @@ -12,9 +12,12 @@ vault-mv \- rename a stored file .B vault rename \&... .SH DESCRIPTION -Renames \fIOLD\fR to \fINEW\fR, which may be in another folder. Fails if -\fINEW\fR exists or is not a valid vault path. File contents are not -re-encrypted. +Renames \fIOLD\fR, a file or a folder and everything under it, to +\fINEW\fR. If \fINEW\fR ends in / or names an existing folder, \fIOLD\fR +moves into it, keeping its base name, as with +.BR mv (1). +Fails, changing nothing, if any new name exists or is not a valid vault +path. File contents are not re-encrypted. .SH OPTIONS .TP .BI \-\-key " PATH" @@ -28,6 +31,7 @@ Vault directory. Default: $VAULT_DIR, else ~/.vault/store. Passphrase, instead of prompting on the terminal. .SH SEE ALSO .BR vault (1), +.BR vault\-cp (1), .BR vault\-rm (1) .SH AUTHOR Andy Dixon , https://www.dixon.cx diff --git a/docs/vault-unlock.1 b/docs/vault-unlock.1 new file mode 100644 index 0000000..c5d16cb --- /dev/null +++ b/docs/vault-unlock.1 @@ -0,0 +1,57 @@ +.TH VAULT-UNLOCK 1 "October 2026" "vault" "User Commands" +.SH NAME +vault-unlock, vault-lock \- remember the unlocked key for a while, or forget it +.SH SYNOPSIS +.B vault unlock +.RB [ \-\-for +.IR DURATION ] +.RB [ \-\-key +.IR PATH ] +.br +.B vault lock +.RB [ \-\-key +.IR PATH ] +.SH DESCRIPTION +.B unlock +asks for the passphrase, checks it, and starts a helper process, much like +.BR ssh\-agent (1), +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 +passphrase prompt. The deadline is fixed when you unlock; using the key does +not extend it. Running +.B unlock +again replaces the helper with a fresh one. +.PP +.B lock +tells the helper to wipe the key and exit at once. +.PP +The key reaches the helper over a pipe and is never written to disk. Its +memory is locked against swapping and excluded from core dumps; on Linux the +helper is also not debuggable. It answers only on a Unix socket inside +\fI$XDG_RUNTIME_DIR/vault-UID\fR (or the temporary directory), a directory +that must belong to this user with mode 0700. Rebooting ends it too. +.PP +While the key is unlocked, any program running as this user can use it, and +it is whichever key the passphrase opened, real or decoy. Lock before +handing the machine to anyone. +.SH OPTIONS +.TP +.BI \-\-for " DURATION" +How long to remember the key, such as 90s, 5m or 2h. Default 5m. +.PP +See also \fB\-\-key\fR in +.BR vault (1). +.SH ENVIRONMENT +.TP +.B VAULT_PASSPHRASE +Passphrase for +.BR unlock . +When set for any other command, it is used instead of the remembered key. +.TP +.B XDG_RUNTIME_DIR +Where the helper's socket directory goes. +.SH SEE ALSO +.BR vault (1), +.BR ssh\-agent (1) +.SH AUTHOR +Andy Dixon , https://www.dixon.cx diff --git a/docs/vault.1 b/docs/vault.1 index 67bc40e..8114de3 100644 --- a/docs/vault.1 +++ b/docs/vault.1 @@ -43,18 +43,30 @@ lists one folder, with sub-folders marked; lists every file under a folder. .TP .BR vault\-get (1) -Decrypt a stored file. +Decrypt a stored file, or a whole folder with +.BR \-r . .TP .BR vault\-mv (1) -Rename a stored file (alias: +Rename or move a stored file or folder (alias: .BR rename ). .TP +.BR vault\-cp (1) +Copy a stored file, or a whole folder with +.BR \-r . +.TP .BR vault\-rm (1) Delete a stored file, or a whole folder with .B \-r (alias: .BR delete ). .TP +.BR vault\-unlock (1) +Remember the unlocked key for a while (default 5 minutes), so commands +skip the passphrase. +.TP +.BR vault\-lock (1) +Forget it now. +.TP .B help Print usage. .SH OPTIONS @@ -74,7 +86,8 @@ Default key file. Default vault directory. .TP .B VAULT_PASSPHRASE -Passphrase, instead of prompting on the terminal. +Passphrase, instead of prompting on the terminal or using a key remembered by +.BR vault\-unlock (1). .TP .B VAULT_DECOY_PASSPHRASE Decoy passphrase used by diff --git a/main.go b/main.go index e5f5929..b6f1218 100644 --- a/main.go +++ b/main.go @@ -46,16 +46,27 @@ const usage = `Usage: vault list [FOLDER] list every stored file under FOLDER, with full names vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name) - vault mv OLD NEW rename a stored file + vault get -r FOLDER [-o DIR] decrypt a folder and everything under it + into DIR (default: the folder's base name) + vault mv OLD NEW rename or move a file or folder; NEW + ending in / or naming an existing folder + moves OLD into it + vault cp [-r] OLD NEW copy a file, or with -r a folder, like mv; + copies share their encrypted data vault rm NAME delete a stored file vault rm -r FOLDER [-f] delete a folder and everything under it; asks first unless -f (or -rf) is given + vault unlock [--for DURATION] remember the passphrase for DURATION + (default 5m), so commands skip asking + vault lock forget it now Options (any command): --key PATH key file (default $VAULT_KEY, else ~/.vault/key) --vault DIR vault directory (default $VAULT_DIR, else ~/.vault/store) -The passphrase is read from the terminal, or from $VAULT_PASSPHRASE. +The passphrase is read from the terminal, or from $VAULT_PASSPHRASE. After +vault unlock, commands use the remembered key instead, unless +$VAULT_PASSPHRASE is set. Decoy passphrase: genkey offers a second, optional passphrase ($VAULT_DECOY_PASSPHRASE when scripted). It unlocks a separate key, so @@ -80,6 +91,10 @@ func main() { os.Exit(2) } cmd, args := os.Args[1], os.Args[2:] + if cmd == agentCmd && len(args) == 2 { + runAgent(args[0], args[1], os.Stdin, os.Stdout) + return + } fset := flag.NewFlagSet("vault "+cmd, flag.ContinueOnError) fset.SetOutput(io.Discard) keyPath := fset.String("key", defaultPath("VAULT_KEY", "key"), "") @@ -87,6 +102,7 @@ func main() { output := fset.String("o", "", "") filler := fset.String("filler", "0", "") yes := fset.Bool("yes", false, "") + ttl := fset.Duration("for", 5*time.Minute, "") // rm's -r and -f; Go's flag package has no combined short flags, so // -rf and -fr are spelled out. recurse, force := fset.Bool("r", false, ""), fset.Bool("f", false, "") @@ -113,6 +129,7 @@ func main() { fail("decoy passphrase must differ from the passphrase") } check(writeKeyFile(*keyPath, pass, decoy)) + agentStop(*keyPath) // one left from an old key at this path would serve it fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath) case "init": want(0, 0) @@ -198,12 +215,40 @@ func main() { want(1, 1) out := *output if out == "" { - out = path.Base(pos[0]) + out = path.Base(strings.TrimRight(pos[0], "/")) } - check(open(*keyPath, *vaultDir, false).get(pos[0], out)) - case "mv", "rename": + s := open(*keyPath, *vaultDir, false) + if !*recurse { + check(s.get(pos[0], out)) + break + } + cat, err := s.catalogue() + check(err) + folder := strings.Trim(pos[0], "/") + names := under(cat, folder) + if folder == "" || len(names) == 0 || len(cat[folder]) > 0 { + fail("%s: not a folder in the vault", pos[0]) + } + for _, n := range names { + dst := filepath.Join(out, filepath.FromSlash(n[len(folder)+1:])) + check(os.MkdirAll(filepath.Dir(dst), 0o700)) + check(s.getRecord(cat[n][0], dst)) + fmt.Fprintf(os.Stderr, "extracted %s\n", dst) + } + case "mv", "rename", "cp": want(2, 2) - check(open(*keyPath, *vaultDir, true).rename(pos[0], pos[1])) + s := open(*keyPath, *vaultDir, true) + cat, err := s.catalogue() + check(err) + pairs, err := transfers(cat, pos[0], pos[1], cmd != "cp" || *recurse) + check(err) + verb := "copied" + if cmd != "cp" { + verb = "moved" + } + check(s.copyAll(cat, pairs, cmd != "cp", func(from, to string) { + fmt.Fprintf(os.Stderr, "%s %s -> %s\n", verb, from, to) + })) case "rm", "delete": want(1, 1) recursive, forced := *recurse || *rf || *fr, *force || *rf || *fr @@ -241,6 +286,26 @@ func main() { if recursive { fmt.Fprintf(os.Stderr, "removed %d file(s), %s\n", len(names), humanSize(size)) } + case "unlock": + want(0, 0) + if *ttl <= 0 { + fail("--for needs a positive duration, such as 5m or 1h") + } + pass, err := term.ReadPassphrase("Enter passphrase:", passphraseEnv) + check(err) + id, err := unlockKeyFile(*keyPath, pass) + check(err) + check(agentStart(*keyPath, id, *ttl)) + id.Close() + fmt.Fprintf(os.Stderr, "unlocked until %s; vault lock forgets the key sooner\n", + time.Now().Add(*ttl).Format("15:04:05")) + case "lock": + want(0, 0) + if agentStop(*keyPath) { + fmt.Fprintln(os.Stderr, "locked") + } else { + fmt.Fprintln(os.Stderr, "nothing was unlocked") + } case "help", "-h", "--help": fmt.Println(usage) default: @@ -248,6 +313,51 @@ func main() { } } +// transfers works out, like mv(1) and cp(1), which stored names src and dst +// mean: each file to copy or move, paired with its new name. src is a file, +// or with recursive a folder. dst ending in / or naming an existing folder +// means into it. No new name may already exist. +func transfers(cat map[string][]*record, src, dst string, recursive bool) ([][2]string, error) { + into := dst == "" || strings.HasSuffix(dst, "/") + src, dst = strings.Trim(src, "/"), strings.Trim(dst, "/") + if src == "" { + return nil, errors.New("name a file or folder to copy or move") + } + if dst == "" || len(cat[dst]) == 0 && len(under(cat, dst)) > 0 { + into = true + } + if into { + dst = path.Join(dst, path.Base(src)) + } + var pairs [][2]string + if len(cat[src]) > 0 { + pairs = [][2]string{{src, dst}} + } else { + names := under(cat, src) + if len(names) == 0 { + return nil, fmt.Errorf("%s: not in the vault", src) + } + if !recursive { + return nil, fmt.Errorf("%s is a folder; use -r", src) + } + if dst == src || strings.HasPrefix(dst, src+"/") { + return nil, fmt.Errorf("cannot put %s inside itself", src) + } + for _, n := range names { + pairs = append(pairs, [2]string{n, dst + n[len(src):]}) + } + } + for _, p := range pairs { + if !validName(p[1]) { + return nil, fmt.Errorf("invalid vault path %q", p[1]) + } + if len(cat[p[1]]) > 0 { + return nil, fmt.Errorf("%s already exists", p[1]) + } + } + return pairs, nil +} + // parseInterleaved lets flags appear before, between or after positional // arguments. func parseInterleaved(fset *flag.FlagSet, args []string) ([]string, error) { @@ -294,9 +404,7 @@ func defaultPath(env, name string) string { // open unlocks the key and opens the vault, holding the vault lock // exclusively for writers and shared for readers. func open(keyPath, vaultDir string, exclusive bool) *store { - pass, err := term.ReadPassphrase("Enter passphrase:", passphraseEnv) - check(err) - id, err := unlockKeyFile(keyPath, pass) + id, err := keyIdentity(keyPath) check(err) s, err := openStore(vaultDir, id) check(err) diff --git a/store.go b/store.go index 80fa894..56aabe6 100644 --- a/store.go +++ b/store.go @@ -586,23 +586,28 @@ func (s *store) removeAll(cat map[string][]*record, names []string, done func(na return nil } -func (s *store) rename(oldName, newName string) error { - if !validName(newName) { - return fmt.Errorf("invalid vault path %q", newName) +// copyAll stores each pair's first name again under its second, sharing its +// objects, then with move deletes the first names. New records go first, so +// an interruption leaves a file under both names, never neither. +func (s *store) copyAll(cat map[string][]*record, pairs [][2]string, move bool, done func(from, to string)) error { + var old []string + for _, p := range pairs { + r := *cat[p[0]][0] + r.Name = p[1] + if !move { + r.Added = time.Now().UTC() + } + if err := s.writeRecord(&r); err != nil { + return err + } + cat[p[1]] = []*record{&r} + old = append(old, p[0]) + done(p[0], p[1]) } - cat, r, err := s.lookup(oldName) - if err != nil { - return err + if !move { + return nil } - if len(cat[newName]) > 0 { - return fmt.Errorf("%s already exists", newName) - } - moved := *r - moved.Name = newName - if err := s.writeRecord(&moved); err != nil { - return err - } - return s.remove(cat, oldName, false) + return s.removeAll(cat, old, func(string) {}) } // get decrypts name to out. Nothing appears at out until every object, @@ -612,6 +617,10 @@ func (s *store) get(name, out string) error { if err != nil { return err } + return s.getRecord(r, out) +} + +func (s *store) getRecord(r *record, out string) error { if _, err := os.Lstat(out); err == nil { return fmt.Errorf("%s already exists", out) } diff --git a/vault_test.go b/vault_test.go index f7ef83f..40b3f69 100644 --- a/vault_test.go +++ b/vault_test.go @@ -9,6 +9,7 @@ import ( "bytes" "compress/gzip" "crypto/rand" + "encoding/binary" "io" "io/fs" "net/http" @@ -75,7 +76,11 @@ func TestStoreLifecycle(t *testing.T) { if cat, _ = s.catalogue(); len(cat) != 2 || len(cat["a/b"]) != 1 { t.Fatalf("catalogue = %v", cat) } - if err := s.rename("a/b", "d"); err != nil { + pairs, err := transfers(cat, "a/b", "d", false) + if err != nil { + t.Fatal(err) + } + if err := s.copyAll(cat, pairs, true, func(string, string) {}); err != nil { t.Fatal(err) } out := filepath.Join(dir, "out") @@ -575,3 +580,113 @@ func TestLs(t *testing.T) { t.Fatal("ls of a missing folder succeeded") } } + +func TestCopyMove(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "key") + if err := writeKeyFile(keyPath, "pass", ""); err != nil { + t.Fatal(err) + } + if err := initStore(filepath.Join(dir, "store"), 0); err != nil { + t.Fatal(err) + } + s := newTestStore(t, keyPath, "pass") + cat, _ := s.catalogue() + src := filepath.Join(dir, "in") + for _, name := range []string{"a/x", "a/b/y", "ab/z", "c/w"} { + os.WriteFile(src, []byte(name), 0o600) + if _, err := s.add(cat, src, name); err != nil { + t.Fatal(err) + } + } + run := func(src, dst string, recursive, move bool) error { + pairs, err := transfers(cat, src, dst, recursive) + if err != nil { + return err + } + return s.copyAll(cat, pairs, move, func(string, string) {}) + } + for _, bad := range [][2]string{{"a", "d"}, {"a/x", "ab/z"}, {"nope", "d"}, {"", "d"}} { + if run(bad[0], bad[1], false, false) == nil { + t.Errorf("cp %s %s succeeded", bad[0], bad[1]) + } + } + if run("a", "a/b", true, true) == nil { + t.Error("moved a folder into itself") + } + if err := run("a", "d", true, false); err != nil { // cp -r a d + t.Fatal(err) + } + if err := run("d", "c/", true, true); err != nil { // mv d c/ + t.Fatal(err) + } + if err := run("ab/z", "c", false, true); err != nil { // mv ab/z into existing folder c + t.Fatal(err) + } + if err := s.removeAll(cat, under(cat, "a"), func(string) {}); err != nil { + t.Fatal(err) + } + fresh, _ := s.catalogue() + var names []string + for n := range fresh { + names = append(names, n) + } + slices.Sort(names) + if want := []string{"c/d/b/y", "c/d/x", "c/w", "c/z"}; !slices.Equal(names, want) { + t.Fatalf("names %v, want %v", names, want) + } + out := filepath.Join(dir, "out") + if err := s.get("c/d/x", out); err != nil { + t.Fatal("copy lost its data when the original went: ", err) + } + if b, _ := os.ReadFile(out); string(b) != "a/x" { + t.Errorf("got %q", b) + } +} + +func TestAgent(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", dir) + t.Setenv(passphraseEnv, "") // restored after the test + os.Unsetenv(passphraseEnv) // so keyIdentity asks the agent + keyPath := filepath.Join(dir, "key") + if err := writeKeyFile(keyPath, "pass", ""); err != nil { + t.Fatal(err) + } + id, err := unlockKeyFile(keyPath, "pass") + if err != nil { + t.Fatal(err) + } + if _, err := agentIdentity(keyPath); err == nil { + t.Fatal("identity from an agent that is not running") + } + sock, err := agentSocket(keyPath) + 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, "1m", bytes.NewReader(secret), pw); close(stopped) }() + if msg, _ := io.ReadAll(pr); string(msg) != "ok" { + t.Fatalf("agent said %q", msg) + } + got, err := keyIdentity(keyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got.Seed(), id.Seed()) || got.SuiteID() != id.SuiteID() { + t.Fatal("agent returned a different key") + } + if !agentStop(keyPath) { + t.Fatal("agent was not running") + } + <-stopped + if _, err := os.Stat(sock); !os.IsNotExist(err) { + t.Fatal("socket left behind") + } + if agentStop(keyPath) { + t.Fatal("agent still running") + } +}