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.
232 lines
5.9 KiB
Go
232 lines
5.9 KiB
Go
// 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)
|
|
}
|