check / check (push) Failing after 2s
A Secure Enclave unlocker add gets the long-term key before it creates the Secure Enclave key, so a wrong passphrase creates none, and deletes the key if a later step fails. macse.CreateKey finds the new key's hash right after sc_auth creates it, failing with an error naming the label if it cannot, and deletes the key if getting its public key then fails. A keychain unlocker add writes all of the unlocker's files before it stores the keychain item, and deletes the item if moving the unlocker into place then fails. A failure to delete is reported along with the original error. The Objective-C and macse_darwin.go were only read, never compiled or run; the new tests run only on a Mac. Model: opus-5-5
942 lines
26 KiB
Go
942 lines
26 KiB
Go
package secret_test
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/macse"
|
|
"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"
|
|
|
|
// privKeyFile is the file that holds the encrypted private key of a version
|
|
// or of a passphrase unlocker.
|
|
const privKeyFile = "priv.age"
|
|
|
|
// 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
|
|
}
|
|
|
|
// dirFiles returns the contents of the files in dir, by name.
|
|
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
|
t.Helper()
|
|
|
|
files := map[string]string{}
|
|
|
|
for _, name := range dirNames(t, fs, dir) {
|
|
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
|
|
require.NoError(t, err)
|
|
|
|
files[name] = string(data)
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
// 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, testMnemonicBuffer(t))
|
|
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.Parallel()
|
|
|
|
const longestName = 255
|
|
|
|
fs := afero.NewOsFs()
|
|
name := strings.Repeat("a", longestName)
|
|
|
|
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
|
|
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)
|
|
|
|
defer got.Destroy()
|
|
|
|
assert.Equal(t, []byte("long"), got.Bytes())
|
|
|
|
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.
|
|
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tfs := range testFilesystems {
|
|
t.Run(tfs.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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)
|
|
|
|
defer value.Destroy()
|
|
|
|
assert.Equal(t, []byte("old"), value.Bytes())
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func TestTempDirsStayOutOfListings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tfs := range testFilesystems {
|
|
t.Run(tfs.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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")
|
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
|
|
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", privKeyFile, "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) == privKeyFile {
|
|
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.
|
|
func TestCurrentFilesNeverMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tfs := range testFilesystems {
|
|
t.Run(tfs.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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) {
|
|
t.Parallel()
|
|
|
|
// No mnemonic, and no current unlocker to get the key from
|
|
base := afero.NewMemMapFs()
|
|
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
|
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)
|
|
}
|
|
|
|
// TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that
|
|
// creating a passphrase unlocker makes, that the unlocker's directory either
|
|
// does not exist or holds all of its files: a crash or a failure at any point
|
|
// leaves no partial unlocker.
|
|
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
|
|
|
|
for _, tfs := range testFilesystems {
|
|
t.Run(tfs.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base, stateDir := tfs.open(t)
|
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
|
testMnemonicBuffer(t))
|
|
require.NoError(t, err)
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
require.NoError(t, err)
|
|
|
|
// The vault has no unlocker yet, so any directory in here is
|
|
// the new one
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
fs := hookFs{Fs: base, before: func(string, string) error {
|
|
for _, name := range dirNames(t, base, unlockersDir) {
|
|
assert.ElementsMatch(t, files,
|
|
dirNames(t, base, filepath.Join(unlockersDir, name)),
|
|
"unlocker directory visible before it was complete")
|
|
}
|
|
|
|
return nil
|
|
}}
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
defer passphrase.Destroy()
|
|
|
|
hooked := vault.NewVault(fs, stateDir, testVaultName)
|
|
hooked.Mnemonic = vlt.Mnemonic
|
|
|
|
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
|
|
// passphrase unlocker twice, each time with only the current unlocker to open
|
|
// the vault. The first replacement fails right after making the new unlocker
|
|
// current, so the old one is not removed. The second checks, before every
|
|
// change it makes, that the vault opens with the passphrase through its
|
|
// current unlocker, which is what a crash at that change would leave; once it
|
|
// returns, the vault must have one passphrase unlocker left.
|
|
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tfs := range testFilesystems {
|
|
t.Run(tfs.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base, stateDir := tfs.open(t)
|
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
|
testMnemonicBuffer(t))
|
|
require.NoError(t, err)
|
|
|
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
|
require.NoError(t, err)
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
|
defer passphrase.Destroy()
|
|
|
|
_, err = vlt.CreatePassphraseUnlocker(passphrase)
|
|
require.NoError(t, err)
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
require.NoError(t, err)
|
|
|
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
|
|
|
// Every change after the switch to the new unlocker fails
|
|
switched := false
|
|
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
|
|
if switched {
|
|
return errInjected
|
|
}
|
|
|
|
switched = op == opRename && path == currentUnlockerPath
|
|
|
|
return nil
|
|
}}
|
|
|
|
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
|
|
replacing.Unlock(ltIdentity)
|
|
|
|
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
|
require.ErrorIs(t, err, errInjected)
|
|
|
|
unlockers, err := vlt.ListUnlockers()
|
|
require.NoError(t, err)
|
|
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
|
|
|
|
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
|
|
checked := hookFs{Fs: base, before: func(string, string) error {
|
|
assertOpens()
|
|
|
|
return nil
|
|
}}
|
|
|
|
replacing = vault.NewVault(checked, stateDir, testVaultName)
|
|
replacing.Unlock(ltIdentity)
|
|
|
|
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
|
require.NoError(t, err)
|
|
assertOpens()
|
|
|
|
unlockers, err = vlt.ListUnlockers()
|
|
require.NoError(t, err)
|
|
assert.Len(t, unlockers, 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
// vaultOpensCheck returns a function that checks that the test vault under
|
|
// stateDir opens through its current unlocker, with passphrase, to the
|
|
// long-term key ltIdentity. Opening it takes a second, so an unlocker
|
|
// directory it has opened through before is not opened again: it must hold
|
|
// the same files as then.
|
|
func vaultOpensCheck(
|
|
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
|
|
passphrase *memguard.LockedBuffer,
|
|
) func() {
|
|
t.Helper()
|
|
|
|
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
|
|
|
|
// The files of each unlocker directory the vault has opened through
|
|
opened := map[string]map[string]string{}
|
|
|
|
return func() {
|
|
t.Helper()
|
|
|
|
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
|
|
require.NoError(t, err)
|
|
|
|
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
|
|
|
|
if before, ok := opened[string(current)]; ok {
|
|
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
|
|
|
|
return
|
|
}
|
|
|
|
opener := vault.NewVault(fs, stateDir, testVaultName)
|
|
opener.UnlockPassphrase = passphrase
|
|
|
|
key, err := opener.UnlockVault()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
|
|
|
|
opened[string(current)] = files
|
|
}
|
|
}
|
|
|
|
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after
|
|
// a file has been written in it, and checks that neither the directory nor
|
|
// its temporary directory is left behind; and, when the temporary directory
|
|
// cannot be removed either, that both failures are reported.
|
|
func TestWriteDirFailureLeavesNothing(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, "unlockers.d")
|
|
target := filepath.Join(listed, "new")
|
|
|
|
writeThenFail := func(tmp string) error {
|
|
require.NoError(t, secret.WriteFileAtomic(base,
|
|
filepath.Join(tmp, unlockerMetadataFile), []byte("{}")))
|
|
|
|
return errInjected
|
|
}
|
|
|
|
err := secret.WriteDir(base, target, writeThenFail)
|
|
require.ErrorIs(t, err, errInjected)
|
|
|
|
// Nothing in the directory that is listed, nor beside it
|
|
assert.Empty(t, dirNames(t, base, listed))
|
|
assert.Equal(t, []string{"unlockers.d"}, dirNames(t, base, dir))
|
|
|
|
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
|
if op == opRemove {
|
|
return os.ErrPermission
|
|
}
|
|
|
|
return nil
|
|
}}
|
|
|
|
err = secret.WriteDir(fs, target, writeThenFail)
|
|
require.ErrorIs(t, err, errInjected)
|
|
require.ErrorIs(t, err, os.ErrPermission)
|
|
assert.Empty(t, dirNames(t, base, listed))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
|
|
// write, when the directory already exists, and leaves the directory as it
|
|
// was: it never writes into a directory in place.
|
|
func TestWriteDirRefusesExistingDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tfs := range testFilesystems {
|
|
t.Run(tfs.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs, dir := tfs.open(t)
|
|
target := filepath.Join(dir, "unlockers.d", "existing")
|
|
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
|
|
require.NoError(t, secret.WriteFileAtomic(fs,
|
|
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
|
|
|
|
err := secret.WriteDir(fs, target, func(string) error {
|
|
t.Error("write called for a directory that exists")
|
|
|
|
return nil
|
|
})
|
|
require.ErrorIs(t, err, os.ErrExist)
|
|
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
|
|
// Enclave unlocker into place fail after its Secure Enclave key is created:
|
|
// the key must be deleted again. Skipped when the add fails before that, as
|
|
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
|
|
// Enclave.
|
|
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mnemonic := testMnemonicBuffer(t)
|
|
base := afero.NewMemMapFs()
|
|
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic)
|
|
require.NoError(t, err)
|
|
|
|
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
|
var seKeyLabel string
|
|
|
|
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
|
|
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
|
|
|
|
return errInjected
|
|
}
|
|
|
|
return nil
|
|
}}
|
|
|
|
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
|
|
nil)
|
|
|
|
if seKeyLabel == "" {
|
|
t.Skipf("the add failed before moving the unlocker into place: %v", err)
|
|
}
|
|
|
|
require.ErrorIs(t, err, errInjected)
|
|
|
|
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
|
|
assert.Error(t, err, "Secure Enclave key left behind")
|
|
}
|