Files
vault/oracle/go/agent.go
T
Andy DixonandClaude 69f4b90777 Freeze the Go implementation as a test-only oracle, with vectors
oracle/go is the Go code at 4c1507c, moved unchanged, plus test-only
generators that record what it computes. tests/vectors holds the
deterministic crypto/store vectors and synthetic fixtures they produce.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-08 09:55:17 +01:00

234 lines
6.1 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 deadline, or on `vault lock`. The deadline is fixed, or with --idle it
// moves on each time a command uses the key.
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, since it started or, with idle, since the key was last used.
func agentStart(keyPath string, id *pqcrypt.PQIdentity, ttl time.Duration, idle bool) error {
sock, err := agentSocket(keyPath)
if err != nil {
return err
}
agentStop(keyPath)
self, err := os.Executable()
if err != nil {
return err
}
mode := "fixed"
if idle {
mode = "idle"
}
cmd := exec.Command(self, agentCmd, sock, ttl.String(), mode)
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, since it started or with mode "idle" since the key was last
// 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() }
ttl, err := time.ParseDuration(ttlArg)
if err != nil || ttl <= 0 || mode != "fixed" && mode != "idle" {
report("bad duration or mode")
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")
expiry := time.AfterFunc(ttl, func() { l.Close() })
for {
c, err := l.Accept()
if err != nil {
break
}
op := serveAgent(c, secret)
c.Close()
if op == agentQuit {
break
}
if op == agentGet && mode == "idle" {
expiry.Reset(ttl)
}
}
l.Close()
clear(secret)
os.Remove(sock)
}
// serveAgent answers one request and returns its operation.
func serveAgent(c net.Conn, secret []byte) byte {
c.SetDeadline(time.Now().Add(agentTimeout))
op := make([]byte, 1)
if _, err := io.ReadFull(c, op); err != nil {
return 0
}
switch op[0] {
case agentGet:
c.Write(secret)
case agentQuit:
clear(secret)
}
return op[0]
}
// 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)
}