Lock the state directory and write vault files atomically (closes #34)
check / check (push) Successful in 1m31s

Each command that changes the state directory holds one lock: flock(2)
on `lock` in the state directory, dropped by the kernel if the process
dies, or a process-wide mutex on the in-memory test filesystem. It
covers the state directory, not each vault, because `currentvault`,
`vault create` and cross-vault moves span vaults, and a lock file in a
vault would be deleted by `vault remove` under a waiting command.

Files go through `secret.WriteFileAtomic`; versions, new secrets and
cross-vault copies are built in a temporary directory and renamed into
place; removals rename out of the way first. Left for later: replacing
an unlocker (#71) and deleting
what an interrupted command leaves under a `.tmp-` name
(#75).

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #69.
This commit is contained in:
2026-10-04 04:57:58 +02:00
committed by clawbot
parent bdb1c7ec18
commit 32a61ff963
24 changed files with 1906 additions and 204 deletions
+86
View File
@@ -0,0 +1,86 @@
package secret
import (
"fmt"
"path/filepath"
"github.com/spf13/afero"
)
// WriteFileAtomic replaces the file at path with data so that a reader, or
// a crash at any moment, finds either the old content or the new, never a
// partial file. The data goes into a temporary file that afero.TempFile
// creates with mode 0600 in the same directory (a rename is only atomic
// within one filesystem), is synced to disk, and is renamed over path. The
// temporary file is removed if any step fails.
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
tmp, err := afero.TempFile(fs, filepath.Dir(path),
"."+filepath.Base(path)+".tmp-*")
if err != nil {
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
}
_, err = tmp.Write(data)
if err == nil {
err = tmp.Sync()
}
closeErr := tmp.Close()
if err == nil {
err = closeErr
}
if err == nil {
err = fs.Rename(tmp.Name(), path)
}
if err != nil {
_ = fs.Remove(tmp.Name())
return fmt.Errorf("failed to write %s: %w", path, err)
}
return nil
}
// TempDirFor creates an empty temporary directory in which to build the
// directory target before renaming it into place, or into which to move
// target before deleting it. It is made in target's grandparent: on the
// same filesystem, so the rename is atomic, and outside target's parent,
// the directory that is listed to find vaults, secrets, versions and
// unlockers, so one left behind by a crash is never taken for one of them.
// Its name leaves out target's, which may already be as long as a file name
// can be.
func TempDirFor(fs afero.Fs, target string) (string, error) {
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
if err != nil {
return "", fmt.Errorf(
"failed to create temporary directory for %s: %w", target, err)
}
return dir, nil
}
// RemoveDirAtomic deletes the directory dir so that it disappears in one
// rename: dir is moved into a new directory from TempDirFor, which is then
// deleted. A crash part-way leaves only that temporary directory behind.
func RemoveDirAtomic(fs afero.Fs, dir string) error {
tmp, err := TempDirFor(fs, dir)
if err != nil {
return err
}
err = fs.Rename(dir, filepath.Join(tmp, filepath.Base(dir)))
if err != nil {
_ = fs.Remove(tmp)
return fmt.Errorf("failed to remove %s: %w", dir, err)
}
err = fs.RemoveAll(tmp)
if err != nil {
return fmt.Errorf("failed to remove %s: %w", dir, err)
}
return nil
}
+672
View File
@@ -0,0 +1,672 @@
package secret_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var errInjected = errors.New("injected failure")
// The kinds of change hookFs passes to before.
const (
opCreate = "create"
opOpen = "open"
opSync = "sync"
opMkdir = "mkdir"
opRemove = "remove"
opRename = "rename"
)
// currentFile is the file in a secret's directory that names its current
// version.
const currentFile = "current"
// unlockerMetadataFile is the file a new unlocker writes last.
const unlockerMetadataFile = "unlocker-metadata.json"
// unlockerPassphrase protects the passphrase unlockers the tests create.
//
//nolint:gosec // G101: test data, not a real credential
const unlockerPassphrase = "unlocker passphrase"
// hookFs passes every call through to Fs, but first calls before for each
// call that changes the filesystem, and for each Sync of a file opened
// through it, with the path it changes (the new path, for Rename). A test
// uses before to inspect the tree at every point where a crash could stop
// the code under test, or returns an error from it to make that call fail.
// If opened is set, OpenFile also tells it the mode it opens each file with.
type hookFs struct {
afero.Fs
before func(op, path string) error
opened func(path string, perm os.FileMode)
}
// hookFile is a file opened through hookFs.
type hookFile struct {
afero.File
before func(op, path string) error
}
func (f hookFile) Sync() error {
err := f.before(opSync, f.Name())
if err != nil {
return err
}
return f.File.Sync()
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) Create(name string) (afero.File, error) {
err := h.before(opCreate, name)
if err != nil {
return nil, err
}
file, err := h.Fs.Create(name)
if err != nil {
return nil, err
}
return hookFile{File: file, before: h.before}, nil
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) OpenFile(
name string, flag int, perm os.FileMode,
) (afero.File, error) {
err := h.before(opOpen, name)
if err != nil {
return nil, err
}
if h.opened != nil {
h.opened(name, perm)
}
file, err := h.Fs.OpenFile(name, flag, perm)
if err != nil {
return nil, err
}
return hookFile{File: file, before: h.before}, nil
}
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
err := h.before(opMkdir, name)
if err != nil {
return err
}
return h.Fs.Mkdir(name, perm)
}
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
err := h.before(opMkdir, path)
if err != nil {
return err
}
return h.Fs.MkdirAll(path, perm)
}
func (h hookFs) Remove(name string) error {
err := h.before(opRemove, name)
if err != nil {
return err
}
return h.Fs.Remove(name)
}
func (h hookFs) RemoveAll(path string) error {
err := h.before(opRemove, path)
if err != nil {
return err
}
return h.Fs.RemoveAll(path)
}
func (h hookFs) Rename(oldname, newname string) error {
err := h.before(opRename, newname)
if err != nil {
return err
}
return h.Fs.Rename(oldname, newname)
}
// testFilesystem is a filesystem to run a test on, with a directory in it
// to work in.
type testFilesystem struct {
name string
open func(t *testing.T) (afero.Fs, string)
}
// testFilesystems are the in-memory filesystem that most tests use and the
// real one: every rename-based guarantee is checked on both.
//
//nolint:gochecknoglobals // read-only table shared by the tests below
var testFilesystems = []testFilesystem{
{"memory", func(*testing.T) (afero.Fs, string) {
return afero.NewMemMapFs(), "/test"
}},
{"real", func(t *testing.T) (afero.Fs, string) {
t.Helper()
return afero.NewOsFs(), t.TempDir()
}},
}
// dirNames lists the names in dir.
func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
t.Helper()
entries, err := afero.ReadDir(fs, dir)
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
return names
}
// writeLongTermKey gives the test vault under stateDir a new long-term key
// and returns it.
func writeLongTermKey(
t *testing.T, fs afero.Fs, stateDir string,
) *age.X25519Identity {
t.Helper()
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
vaultDir, err := vault.GetDirectory()
require.NoError(t, err)
require.NoError(t, fs.MkdirAll(vaultDir, 0o700))
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
[]byte(ltIdentity.Recipient().String()), 0o600))
return ltIdentity
}
// newVaultWithSecret creates the vault name under stateDir from the test
// mnemonic, with a secret "shared" in it that holds value.
func newVaultWithSecret(
t *testing.T, fs afero.Fs, stateDir, name, value string,
) *vault.Vault {
t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name)
require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value))
defer buffer.Destroy()
require.NoError(t, vlt.AddSecret("shared", buffer, false))
return vlt
}
func TestWriteFileAtomicReplacesFile(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
fs, dir := tfs.open(t)
path := filepath.Join(dir, currentFile)
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("old")))
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("new")))
data, err := afero.ReadFile(fs, path)
require.NoError(t, err)
assert.Equal(t, "new", string(data))
info, err := fs.Stat(path)
require.NoError(t, err)
assert.Equal(t, secret.FilePerms, info.Mode().Perm())
// No temporary file is left next to it
assert.Equal(t, []string{currentFile}, dirNames(t, fs, dir))
})
}
}
func TestWriteFileAtomicFailureKeepsOldFile(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
path := filepath.Join(dir, currentFile)
require.NoError(t, secret.WriteFileAtomic(base, path, []byte("old")))
fs := hookFs{Fs: base, before: func(op, _ string) error {
if op == opRename {
return errInjected
}
return nil
}}
err := secret.WriteFileAtomic(fs, path, []byte("new"))
require.ErrorIs(t, err, errInjected)
data, err := afero.ReadFile(base, path)
require.NoError(t, err)
assert.Equal(t, "old", string(data))
// The temporary file is removed again
assert.Equal(t, []string{currentFile}, dirNames(t, base, dir))
})
}
}
// TestRemoveDirAtomic checks that RemoveDirAtomic deletes nothing where the
// directory stands, which a crash could stop half-way, and that it leaves
// nothing behind.
func TestRemoveDirAtomic(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
listed := filepath.Join(dir, "secrets.d")
target := filepath.Join(listed, "doomed")
require.NoError(t, base.MkdirAll(filepath.Join(target, "versions"), 0o700))
require.NoError(t, secret.WriteFileAtomic(base,
filepath.Join(target, currentFile), []byte("20231216.001")))
fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRemove && strings.HasPrefix(path, target) {
t.Errorf("deleted %s where it stands", path)
}
return nil
}}
require.NoError(t, secret.RemoveDirAtomic(fs, target))
// Gone, and no temporary directory is left in the directory
// that is listed or in the one above it
assert.Empty(t, dirNames(t, base, listed))
assert.Equal(t, []string{"secrets.d"}, dirNames(t, base, dir))
})
}
}
// TestLongestNames adds a secret to a vault and removes the vault, both
// named with 255 bytes, the most a file name may have, on the real
// filesystem: the temporary directories they use must fit that limit too.
func TestLongestNames(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
const longestName = 255
fs := afero.NewOsFs()
name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret(name, value, false))
got, err := vlt.GetSecret(name)
require.NoError(t, err)
assert.Equal(t, "long", string(got))
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
require.NoError(t, secret.RemoveDirAtomic(fs, vaultDir))
assert.NoDirExists(t, vaultDir)
}
// TestForcedCopyKeepsDestinationUntilReplaced copies a secret over one in
// another vault, as a forced move between vaults does, and makes the last
// step that completes the copy fail. The secret it was to replace must
// still be there unchanged: it may go only once its replacement is whole.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
base, stateDir := tfs.open(t)
src := newVaultWithSecret(t, base, stateDir, "source", "new")
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
// The copy is complete once its current file is written
fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRename && filepath.Base(path) == currentFile {
return errInjected
}
return nil
}}
err := vault.NewVault(fs, stateDir, "dest").
CopySecretAllVersions(src, "shared", "shared", true)
require.ErrorIs(t, err, errInjected)
value, err := dest.GetSecret("shared")
require.NoError(t, err)
assert.Equal(t, "old", string(value))
})
}
}
// TestTempDirsStayOutOfListings adds a version, adds a secret, copies a
// secret over another and removes one, and checks that none of them makes a
// directory directly in secrets.d or in a versions directory. Those are
// listed to find secrets and versions, so a temporary directory made there
// would be listed while half-built, and one left by a crash would stay.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestTempDirsStayOutOfListings(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
base, stateDir := tfs.open(t)
newVaultWithSecret(t, base, stateDir, "default", "first")
fs := hookFs{Fs: base, before: func(op, path string) error {
parent := filepath.Base(filepath.Dir(path))
if op == opMkdir && (parent == "secrets.d" || parent == "versions") {
t.Errorf("made %s where it is listed", path)
}
return nil
}}
vlt := vault.NewVault(fs, stateDir, "default")
value := memguard.NewBufferFromBytes([]byte("second"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("shared", value, true))
require.NoError(t, vlt.AddSecret("other", value, false))
require.NoError(t, vlt.CopySecretAllVersions(vlt, "shared", "other", true))
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
require.NoError(t, secret.RemoveDirAtomic(fs,
filepath.Join(vaultDir, "secrets.d", "shared")))
})
}
}
// TestVersionSaveIsWholeOrAbsent checks, before every change Save makes and
// once after it returns, that the version directory either does not exist
// or holds all of its files: a crash at any point leaves no version that
// cannot be decrypted.
func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
ltIdentity := writeLongTermKey(t, base, stateDir)
var versionDir string
checkVersionDir := func(string, string) error {
exists, err := afero.DirExists(base, versionDir)
require.NoError(t, err)
if exists {
assert.ElementsMatch(t,
[]string{"pub.age", "value.age", "priv.age", "metadata.age"},
dirNames(t, base, versionDir),
"version directory visible before it was complete")
}
return nil
}
fs := hookFs{Fs: base, before: checkVersionDir}
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
versionDir = sv.Directory
value := memguard.NewBufferFromBytes([]byte("whole or nothing"))
defer value.Destroy()
require.NoError(t, sv.Save(value))
require.NoError(t, checkVersionDir("", ""))
got, err := sv.GetValue(ltIdentity)
require.NoError(t, err)
defer got.Destroy()
assert.Equal(t, "whole or nothing", got.String())
})
}
}
// TestVersionSaveFailureLeavesNothing makes the write of the encrypted
// private key fail, after the value has been written, and checks that
// neither the version nor its temporary directory is left behind.
func TestVersionSaveFailureLeavesNothing(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
writeLongTermKey(t, base, stateDir)
fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRename && filepath.Base(path) == "priv.age" {
return errInjected
}
return nil
}}
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
value := memguard.NewBufferFromBytes([]byte("never stored"))
defer value.Destroy()
require.ErrorIs(t, sv.Save(value), errInjected)
// The secret directory holds only the empty versions directory
versionsDir := filepath.Dir(sv.Directory)
assert.Equal(t, []string{"versions"},
dirNames(t, base, filepath.Dir(versionsDir)))
assert.Empty(t, dirNames(t, base, versionsDir))
})
}
}
// TestCurrentFilesNeverMissing selects the current version, vault and
// unlocker again and checks, before each change this makes, that the file
// naming the current one exists: a reader or a crash never finds it
// missing.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestCurrentFilesNeverMissing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
base, stateDir := tfs.open(t)
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
// Created as the current unlocker
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
secretDir := filepath.Join(vaultDir, "secrets.d", "shared")
version, err := secret.GetCurrentVersion(base, secretDir)
require.NoError(t, err)
for _, tc := range []struct {
path string
reselect func(fs afero.Fs) error
}{
{filepath.Join(secretDir, currentFile), func(fs afero.Fs) error {
return secret.SetCurrentVersion(fs, secretDir, version)
}},
{filepath.Join(stateDir, "currentvault"), func(fs afero.Fs) error {
return vault.SelectVault(fs, stateDir, testVaultName)
}},
{filepath.Join(vaultDir, "current-unlocker"), func(fs afero.Fs) error {
return vault.NewVault(fs, stateDir, testVaultName).
SelectUnlocker(unlocker.GetID())
}},
} {
fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.Exists(base, tc.path)
require.NoError(t, err)
assert.True(t, exists, "%s is missing", filepath.Base(tc.path))
return nil
}}
require.NoError(t, tc.reselect(fs))
}
})
}
}
// TestWriteFileAtomicTempFile checks that WriteFileAtomic creates its
// temporary file with mode 0600, rather than wider and narrowed later, so
// that no other user can ever read it, and syncs it before renaming it into
// place, so that a crash cannot leave the file named but its data lost.
func TestWriteFileAtomicTempFile(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
var modes []os.FileMode
synced := false
fs := hookFs{
Fs: base,
before: func(op, _ string) error {
switch op {
case opSync:
synced = true
case opRename:
assert.True(t, synced, "renamed before syncing")
}
return nil
},
opened: func(_ string, perm os.FileMode) {
modes = append(modes, perm)
},
}
require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(dir, currentFile), []byte("new")))
assert.Equal(t, []os.FileMode{secret.FilePerms}, modes)
})
}
}
// TestPassphraseUnlockerGetsKeyFirst creates a passphrase unlocker in a
// vault whose long-term key cannot be had: it must fail without writing
// anything, so that it never leaves a partial unlocker, nor breaks the one
// it would replace.
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// No mnemonic, and no current unlocker to get the key from
t.Setenv(secret.EnvMnemonic, "")
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
t.Errorf("changed %s before getting the long-term key", path)
return nil
}}
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
CreatePassphraseUnlocker(passphrase)
require.Error(t, err)
}
// TestPassphraseUnlockerWritesMetadataLast checks that the last file a new
// passphrase unlocker writes in its directory is its metadata: an unlocker
// directory without metadata is never used, so one interrupted earlier
// cannot be.
func TestPassphraseUnlockerWritesMetadataLast(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
var last string
fs := hookFs{Fs: base, before: func(_, path string) error {
if filepath.Dir(path) == unlockerDir {
last = filepath.Base(path)
}
return nil
}}
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.Equal(t, unlockerMetadataFile, last)
}
+6 -6
View File
@@ -189,7 +189,7 @@ func (k *KeychainUnlocker) Remove() error {
// Step 3: Remove directory
Debug("Removing keychain unlocker directory", "directory", k.Directory)
if err := k.fs.RemoveAll(k.Directory); err != nil {
if err := RemoveDirAtomic(k.fs, k.Directory); err != nil {
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
@@ -368,7 +368,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
// Step 3: Store age recipient as plaintext
ageRecipient := ageIdentity.Recipient().String()
recipientPath := filepath.Join(unlockerDir, "pub.txt")
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
if err := WriteFileAtomic(fs, recipientPath, []byte(ageRecipient)); err != nil {
return nil, fmt.Errorf("failed to write age recipient: %w", err)
}
@@ -384,7 +384,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
if err := WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
@@ -403,7 +403,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
if err := WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
@@ -440,9 +440,9 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
if err := afero.WriteFile(fs,
if err := WriteFileAtomic(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"),
metadataBytes, FilePerms); err != nil {
metadataBytes); err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
+1 -1
View File
@@ -127,7 +127,7 @@ func (p *PassphraseUnlocker) Remove() error {
// For passphrase unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
err := p.fs.RemoveAll(p.Directory)
err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil {
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
}
+6 -7
View File
@@ -172,7 +172,7 @@ func (p *PGPUnlocker) GetID() string {
func (p *PGPUnlocker) Remove() error {
// For PGP unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
err := p.fs.RemoveAll(p.Directory)
err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil {
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
}
@@ -275,7 +275,7 @@ func CreatePGPUnlocker(
ageRecipient := ageIdentity.Recipient().String()
recipientPath := filepath.Join(unlockerDir, "pub.txt")
err = afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms)
err = WriteFileAtomic(fs, recipientPath, []byte(ageRecipient))
if err != nil {
return nil, fmt.Errorf("failed to write age recipient: %w", err)
}
@@ -298,7 +298,7 @@ func CreatePGPUnlocker(
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms)
err = WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge)
if err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
@@ -315,7 +315,7 @@ func CreatePGPUnlocker(
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
err = afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms)
err = WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey)
if err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
@@ -357,9 +357,8 @@ func writePGPUnlockerMetadata(
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
err = afero.WriteFile(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"),
metadataBytes, FilePerms)
err = WriteFileAtomic(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
+3 -3
View File
@@ -148,7 +148,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
}
Debug("Removing SE unlocker directory", "directory", s.Directory)
if err := s.fs.RemoveAll(s.Directory); err != nil {
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
}
@@ -271,7 +271,7 @@ func CreateSecureEnclaveUnlocker(
// Write SE-encrypted long-term key
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
return nil, fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
@@ -295,7 +295,7 @@ func CreateSecureEnclaveUnlocker(
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
return nil, fmt.Errorf("failed to write metadata: %w", err)
}
+43 -25
View File
@@ -132,7 +132,10 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
return fmt.Sprintf("%s.%03d", today, newSerial), nil
}
// Save saves the version metadata and value
// Save saves the version metadata and value. The files are written into a
// temporary directory that is renamed to sv.Directory once all of them are
// complete, so the version directory is either whole or absent, even if the
// process dies part-way.
func (sv *Version) Save(value *memguard.LockedBuffer) error {
if value == nil {
return errNilValueBuffer
@@ -146,14 +149,22 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
fs := sv.vault.GetFilesystem()
// Create version directory
err := fs.MkdirAll(sv.Directory, DirPerms)
// Create the versions directory the finished version is renamed into
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
if err != nil {
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
Debug("Failed to create versions directory", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to create version directory: %w", err)
return fmt.Errorf("failed to create versions directory: %w", err)
}
tmpDir, err := TempDirFor(fs, sv.Directory)
if err != nil {
return err
}
// Once the rename below has moved it into place, this finds nothing.
defer func() { _ = fs.RemoveAll(tmpDir) }()
// Generate a new keypair for this version
Debug("Generating version-specific keypair", "version", sv.Version)
@@ -174,21 +185,28 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
slog.String("public_key", versionIdentity.Recipient().String()),
)
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
if err != nil {
return err
}
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
if err != nil {
return err
}
err = sv.writeEncryptedMetadata(fs, versionIdentity)
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
if err != nil {
return err
}
err = fs.Rename(tmpDir, sv.Directory)
if err != nil {
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to move version into place: %w", err)
}
Debug("Successfully saved secret version",
"version", sv.Version, "secret_name", sv.SecretName)
@@ -359,17 +377,18 @@ func (sv *Version) GetValue(
}
// writePublicKeyAndValue stores the version's public key and the value
// encrypted to it.
// encrypted to it in dir.
func (sv *Version) writePublicKeyAndValue(
fs afero.Fs,
dir string,
versionIdentity *age.X25519Identity,
value *memguard.LockedBuffer,
) error {
versionPublicKey := versionIdentity.Recipient().String()
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
pubKeyPath := filepath.Join(dir, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
if err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
@@ -386,10 +405,10 @@ func (sv *Version) writePublicKeyAndValue(
return fmt.Errorf("failed to encrypt version value: %w", err)
}
valuePath := filepath.Join(sv.Directory, "value.age")
valuePath := filepath.Join(dir, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
err = WriteFileAtomic(fs, valuePath, encryptedValue)
if err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
@@ -400,9 +419,10 @@ func (sv *Version) writePublicKeyAndValue(
}
// writeEncryptedPrivateKey encrypts the version's private key to the
// vault's long-term public key and stores it.
// vault's long-term public key and stores it in dir.
func (sv *Version) writeEncryptedPrivateKey(
fs afero.Fs,
dir string,
versionPrivateKeyBuffer *memguard.LockedBuffer,
) error {
vaultDir, _ := sv.vault.GetDirectory()
@@ -436,10 +456,10 @@ func (sv *Version) writeEncryptedPrivateKey(
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
privKeyPath := filepath.Join(sv.Directory, "priv.age")
privKeyPath := filepath.Join(dir, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
if err != nil {
Debug("Failed to write encrypted version private key",
"error", err, "path", privKeyPath)
@@ -451,9 +471,10 @@ func (sv *Version) writeEncryptedPrivateKey(
}
// writeEncryptedMetadata encrypts the version metadata to the version's
// public key and stores it.
// public key and stores it in dir.
func (sv *Version) writeEncryptedMetadata(
fs afero.Fs,
dir string,
versionIdentity *age.X25519Identity,
) error {
Debug("Encrypting version metadata", "version", sv.Version)
@@ -477,10 +498,10 @@ func (sv *Version) writeEncryptedMetadata(
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(sv.Directory, "metadata.age")
metadataPath := filepath.Join(dir, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
if err != nil {
Debug("Failed to write encrypted version metadata",
"error", err, "path", metadataPath)
@@ -553,15 +574,12 @@ func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
}
// SetCurrentVersion updates the "current" file to point to a specific version
// The file contains just the version name (e.g., "20231215.001")
// The file contains just the version name (e.g., "20231215.001"). It is
// replaced in one rename, so once written it always exists.
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current")
// Remove existing file if it exists
_ = fs.Remove(currentPath)
// Write just the version name to the file
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
err := WriteFileAtomic(fs, currentPath, []byte(version))
if err != nil {
return fmt.Errorf("failed to create current version file: %w", err)
}