Add cp, folder mv, get -r, and unlock/lock to remember the key

Filesystem basics: mv now moves folders as well as files, and like mv(1)
a NEW ending in / or naming an existing folder means into it. cp copies a
file, or a folder with -r, by writing new records that share the
original's objects, so it is instant and takes no space; rm already keeps
objects another record still uses. New records go first, so an
interrupted move leaves both names, never neither. get -r extracts a
folder, keeping its structure.

vault unlock [--for 5m] checks the passphrase and starts the binary again
as a hidden __agent process, handing it the suite ID and seed over a pipe.
The agent keeps them in mlocked memory, with core dumps off and (on Linux)
PR_SET_DUMPABLE cleared, serves them on a Unix socket in a 0700 per-user
directory whose owner is checked on every use, and wipes them and exits
at a hard deadline or on vault lock. Commands ask it first unless
VAULT_PASSPHRASE is set, and genkey stops any agent left for that path.

Guide, man pages (new vault-cp, vault-unlock, vault-lock), README and
screenshots updated; the guide's error table is split so it fits.
This commit is contained in:
Andy Dixon
2026-10-02 22:13:06 +01:00
parent 0d45ecea59
commit ec204f0ea2
31 changed files with 807 additions and 51 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+17
View File
@@ -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"
+85 -16
View File
@@ -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}
+37
View File
@@ -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 <vault@dixon.cx>, https://www.dixon.cx
+11 -1
View File
@@ -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).
+1
View File
@@ -0,0 +1 @@
.so man1/vault-unlock.1
+8 -4
View File
@@ -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 <vault@dixon.cx>, https://www.dixon.cx
+57
View File
@@ -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 <vault@dixon.cx>, https://www.dixon.cx
+16 -3
View File
@@ -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