Give every new unlocker a directory of its own (closes #71)
check / check (push) Failing after 2s

A passphrase unlocker added to a vault that had one, and a PGP, keychain
or Secure Enclave unlocker added on the same day as another of its type,
were written into the existing unlocker's directory file by file, so a
crash part-way left a current unlocker whose files did not belong
together.

Unlocker directories, keychain items and Secure Enclave keys are now
named with the time to the nanosecond, and secret.WriteDir refuses a
directory that exists. Adding a passphrase unlocker writes the new one,
points current-unlocker at it, and only then removes the vault's other
passphrase unlockers.

Model: opus-5-5
This commit is contained in:
2026-10-04 14:28:25 +00:00
parent db7d2c952e
commit be56ae533c
13 changed files with 309 additions and 70 deletions
+63 -10
View File
@@ -2,8 +2,10 @@ package vault
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
@@ -103,7 +105,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
// resolveUnlockerDirectory reads the current-unlocker file to get the
// unlocker directory path
// The file contains just the unlocker name (e.g., "passphrase")
// The file contains just the name of the unlocker's directory in unlockers.d
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
@@ -341,7 +343,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return nil
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
// directory of its own, makes it the current unlocker, and only then removes
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
// point leaves a complete current unlocker, the old one or the new.
// The passphrase must be provided as a LockedBuffer for security
func (v *Vault) CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer,
@@ -353,13 +358,23 @@ func (v *Vault) CreatePassphraseUnlocker(
// We need to get the long-term key (either from memory if unlocked, or
// derive it). Getting it before anything is written means failing to
// get it changes nothing, even when replacing the current unlocker.
// get it changes nothing.
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// The passphrase unlockers the new one replaces
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
if err != nil {
return nil, err
}
createdAt := time.Now()
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
createdAt.UTC().Format(secret.UnlockerTimeFormat))
// Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity()
@@ -379,7 +394,7 @@ func (v *Vault) CreatePassphraseUnlocker(
metadata := UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Now(),
CreatedAt: createdAt,
Flags: []string{},
}
@@ -397,16 +412,54 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, err
}
// Create the unlocker instance
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
// Select the new unlocker by its directory, not by its ID: an old
// passphrase unlocker created in the same minute has the same ID.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Select this unlocker as current
err = v.SelectUnlocker(unlocker.GetID())
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
[]byte(filepath.Base(unlockerDir)))
if err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
}
return unlocker, nil
for _, oldDir := range oldDirs {
err = secret.RemoveDirAtomic(v.fs, oldDir)
if err != nil {
return nil, fmt.Errorf(
"created and selected the new passphrase unlocker: %w", err)
}
}
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
}
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
// passphrase unlockers. A directory ListUnlockers skips is left out, with the
// same warning.
func (v *Vault) passphraseUnlockerDirs(unlockersDir string) ([]string, error) {
files, err := afero.ReadDir(v.fs, unlockersDir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
}
var dirs []string
for _, file := range files {
if !file.IsDir() {
continue
}
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok && metadata.Type == unlockerTypePassphrase {
dirs = append(dirs, filepath.Join(unlockersDir, file.Name()))
}
}
return dirs, nil
}
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in