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

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 was merged in pull request #99.
This commit is contained in:
2026-10-04 16:58:45 +02:00
parent db7d2c952e
commit 7e4e0f7806
13 changed files with 309 additions and 70 deletions
+6 -3
View File
@@ -197,6 +197,9 @@ Creates a new unlocker of the specified type:
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified) - `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
A vault has one passphrase unlocker: adding one replaces the one the vault
has, which is removed only once the new one is the current unlocker.
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑 #### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command **DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
@@ -243,8 +246,8 @@ Decrypts data using an Age key stored as a secret.
├── vaults.d/ ├── vaults.d/
│ ├── default/ │ ├── default/
│ │ ├── unlockers.d/ │ │ ├── unlockers.d/
│ │ │ ├── passphrase/ # Passphrase unlocker │ │ │ ├── passphrase-<time>/ # Passphrase unlocker
│ │ │ └── pgp/ # PGP unlocker │ │ │ └── <host>-pgp-<time>/ # PGP unlocker
│ │ ├── secrets.d/ │ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key │ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/ │ │ │ │ ├── versions/
@@ -260,7 +263,7 @@ Decrypts data using an Age key stored as a secret.
│ │ │ └── current -> versions/20231215.001 │ │ │ └── current -> versions/20231215.001
│ │ ├── vault-metadata.json # Vault metadata │ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key │ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker -> ../unlockers.d/passphrase │ │ └── current-unlocker # Current unlocker's directory name
│ └── work/ │ └── work/
│ ├── unlockers.d/ │ ├── unlockers.d/
│ ├── secrets.d/ │ ├── secrets.d/
+15 -9
View File
@@ -25,6 +25,20 @@ Bring the repo into policy compliance in one commit:
# Completed Steps # Completed Steps
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
Enclave unlocker the keychain item or Secure Enclave key, which names the
directory, carries the time instead of the day. `secret.WriteDir` fails on a
directory that exists instead of writing into it. `unlocker add passphrase`
writes the new unlocker, makes it current, and only then removes the vault's
other passphrase unlockers; a crash between the last two steps leaves the old
one beside the new, and the old passphrase still opens the vault through it
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
another of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once - 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
unset at once, so that no program the command runs, `gpg` included, unset at once, so that no program the command runs, `gpg` included,
@@ -79,9 +93,7 @@ Bring the repo into policy compliance in one commit:
and encrypt everything before writing anything. All four unlocker and encrypt everything before writing anything. All four unlocker
types write their files through `secret.WriteDir`: a new unlocker is types write their files through `secret.WriteDir`: a new unlocker is
built in a temporary directory, renamed into place when complete and built in a temporary directory, renamed into place when complete and
removed on a failure. One added under the directory name of an removed on a failure.
existing unlocker is still written into that directory in place
(https://git.eeqj.de/sneak/secret/issues/71).
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` - 2026-10-04: `secret unlocker select` and `secret unlocker remove`
skip, with the warning `unlocker list` gives, an unlocker directory skip, with the warning `unlocker list` gives, an unlocker directory
whose metadata file cannot be checked for, read or parsed, instead of whose metadata file cannot be checked for, read or parsed, instead of
@@ -177,12 +189,6 @@ Bring the repo into policy compliance in one commit:
into place, and removals rename out of the way first, so a version into place, and removals rename out of the way first, so a version
or secret is never half-added and never half-removed. An or secret is never half-added and never half-removed. An
interrupted command can still leave: interrupted command can still leave:
- a broken unlocker, when it was replacing one: an unlocker added
under the directory name of an existing one is rewritten file by
file. That happens to a passphrase unlocker added to a vault that
has one, and to a PGP, keychain or Secure Enclave unlocker added
on the same host and day as another of its type
(https://git.eeqj.de/sneak/secret/issues/71);
- from `init` or `vault create` killed after the passphrase prompt - from `init` or `vault create` killed after the passphrase prompt
but before the unlocker is written, a vault with no unlocker, but before the unlocker is written, a vault with no unlocker,
which `vault create` has already made the current vault; which `vault create` has already made the current vault;
+10 -9
View File
@@ -366,8 +366,15 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d") unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
verifyFileExists(t, unlockersDir) verifyFileExists(t, unlockersDir)
// Check current-unlocker file names the unlocker's directory
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify passphrase unlocker was created // Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(unlockersDir, "passphrase") passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent))
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker metadata // Check unlocker metadata
@@ -382,13 +389,6 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age") encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
verifyFileExists(t, encryptedLTPubKey) verifyFileExists(t, encryptedLTPubKey)
// Check current-unlocker file contains the relative path
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify vault-metadata.json in vault // Verify vault-metadata.json in vault
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json") vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
verifyFileExists(t, vaultMetadata) verifyFileExists(t, vaultMetadata)
@@ -537,7 +537,8 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
verifyFileExists(t, pubKeyFile) verifyFileExists(t, pubKeyFile)
// Verify passphrase unlocker was created // Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase") currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker files // Check unlocker files
+1 -1
View File
@@ -181,7 +181,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
vaultDir := testStateDir + "/vaults.d/default" vaultDir := testStateDir + "/vaults.d/default"
require.Contains(t, before, vaultDir+"/secrets.d/x/") require.Contains(t, before, vaultDir+"/secrets.d/x/")
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/") require.Contains(t, before, vaultDir+"/current-unlocker")
require.Equal(t, "default", before[testStateDir+"/currentvault"]) require.Equal(t, "default", before[testStateDir+"/currentvault"])
cmd := &cobra.Command{} cmd := &cobra.Command{}
+2 -2
View File
@@ -603,8 +603,8 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID()) cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// Auto-select the newly created unlocker // CreatePassphraseUnlocker has already made it the current unlocker
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID()) cmd.Printf("Automatically selected as current unlocker\n")
return nil return nil
} }
+8 -8
View File
@@ -3,6 +3,7 @@ package secret
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -62,13 +63,12 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
return dir, nil return dir, nil
} }
// WriteDir calls write to write the files of the directory dir. When dir does // WriteDir calls write to write the files of the new directory dir into a
// not exist yet, write writes them into a temporary directory from TempDirFor, // temporary directory from TempDirFor, which is then renamed to dir, so that
// which is then renamed to dir, so that neither a failure nor a crash leaves // neither a failure nor a crash leaves dir half-written; on a failure the
// dir half-written; on a failure the temporary directory is removed, and a // temporary directory is removed, and a failure to remove it is returned
// failure to remove it is returned along with the first. A directory cannot be // along with the first. A directory cannot be replaced in one rename, so if
// renamed over one that has files in it, so when dir already exists, write // dir already exists, WriteDir fails without calling write.
// writes into it in place; dir is then never removed.
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error { func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
exists, err := afero.Exists(fs, dir) exists, err := afero.Exists(fs, dir)
if err != nil { if err != nil {
@@ -76,7 +76,7 @@ func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
} }
if exists { if exists {
return write(dir) return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
} }
// Create the directory the finished one is renamed into // Create the directory the finished one is renamed into
+150 -17
View File
@@ -191,6 +191,22 @@ func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
return names 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 // writeLongTermKey gives the test vault under stateDir a new long-term key
// and returns it. // and returns it.
func writeLongTermKey( func writeLongTermKey(
@@ -668,14 +684,14 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase") // 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 { fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.DirExists(base, unlockerDir) for _, name := range dirNames(t, base, unlockersDir) {
require.NoError(t, err) assert.ElementsMatch(t, files,
dirNames(t, base, filepath.Join(unlockersDir, name)),
if exists {
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir),
"unlocker directory visible before it was complete") "unlocker directory visible before it was complete")
} }
@@ -688,13 +704,130 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
hooked := vault.NewVault(fs, stateDir, testVaultName) hooked := vault.NewVault(fs, stateDir, testVaultName)
hooked.Mnemonic = vlt.Mnemonic hooked.Mnemonic = vlt.Mnemonic
_, err = hooked.CreatePassphraseUnlocker(passphrase) unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err) require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir)) 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 // TestWriteDirFailureLeavesNothing makes writing a new directory fail after
// a file has been written in it, and checks that neither the directory nor // 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 // its temporary directory is left behind; and, when the temporary directory
@@ -740,10 +873,10 @@ func TestWriteDirFailureLeavesNothing(t *testing.T) {
} }
} }
// TestWriteDirKeepsExistingDir makes writing into a directory that already // TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
// exists fail, and checks that the directory, with what was in it, is still // write, when the directory already exists, and leaves the directory as it
// there: WriteDir writes into it in place and never removes it. // was: it never writes into a directory in place.
func TestWriteDirKeepsExistingDir(t *testing.T) { func TestWriteDirRefusesExistingDir(t *testing.T) {
t.Parallel() t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
@@ -751,17 +884,17 @@ func TestWriteDirKeepsExistingDir(t *testing.T) {
t.Parallel() t.Parallel()
fs, dir := tfs.open(t) fs, dir := tfs.open(t)
target := filepath.Join(dir, "unlockers.d", "passphrase") target := filepath.Join(dir, "unlockers.d", "existing")
require.NoError(t, fs.MkdirAll(target, secret.DirPerms)) require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
require.NoError(t, secret.WriteFileAtomic(fs, require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(target, unlockerMetadataFile), []byte("{}"))) filepath.Join(target, unlockerMetadataFile), []byte("{}")))
err := secret.WriteDir(fs, target, func(got string) error { err := secret.WriteDir(fs, target, func(string) error {
assert.Equal(t, target, got) t.Error("write called for a directory that exists")
return errInjected return nil
}) })
require.ErrorIs(t, err, errInjected) require.ErrorIs(t, err, os.ErrExist)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target)) assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
}) })
} }
+6
View File
@@ -16,6 +16,12 @@ const (
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
// EnvGPGKeyID is the environment variable for providing the GPG key ID // EnvGPGKeyID is the environment variable for providing the GPG key ID
EnvGPGKeyID = "SB_GPG_KEY_ID" EnvGPGKeyID = "SB_GPG_KEY_ID"
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
// new unlocker's directory, keychain item and Secure Enclave key. It runs
// to the nanosecond, so that every new unlocker, even one added right
// after another, gets a directory of its own.
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
) )
// File system permission constants // File system permission constants
+3 -3
View File
@@ -233,10 +233,10 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
// Format: secret-<vault>-<hostname>-<date> // Format: secret-<vault>-<hostname>-<time>
enrollmentDate := time.Now().Format("2006-01-02") enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
} }
// getLongTermPrivateKey derives the long-term private key from mnemonic when // getLongTermPrivateKey derives the long-term private key from mnemonic when
+5 -6
View File
@@ -209,21 +209,20 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
} }
// generatePGPUnlockerName generates a unique name for the PGP unlocker // generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and date // based on hostname and time
func generatePGPUnlockerName() (string, error) { func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
// Format: hostname-pgp-YYYY-MM-DD enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
} }
// pgpUnlockerDir returns the current vault and the directory in it for a // pgpUnlockerDir returns the current vault and the directory in it for a
// new PGP unlocker, named after the host and the day. // new PGP unlocker, named after the host and the time.
// //
//nolint:ireturn // the vault is only available behind VaultInterface //nolint:ireturn // the vault is only available behind VaultInterface
func pgpUnlockerDir( func pgpUnlockerDir(
@@ -235,7 +234,7 @@ func pgpUnlockerDir(
return nil, "", fmt.Errorf("failed to get current vault: %w", err) return nil, "", fmt.Errorf("failed to get current vault: %w", err)
} }
// Generate the unlocker name based on hostname and date // Generate the unlocker name based on hostname and time
unlockerName, err := generatePGPUnlockerName() unlockerName, err := generatePGPUnlockerName()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err) return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
+38
View File
@@ -7,6 +7,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -64,3 +65,40 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d"))) assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
} }
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
// the other, so on the same host and day, and checks that the second gets a
// directory of its own and leaves the first one's files as they were.
// CreatePGPUnlocker does not check whether the GPG key already has an
// unlocker, so the test key serves for both.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
installFakeGPG(t)
original := secret.GPGEncryptFunc
t.Cleanup(func() { secret.GPGEncryptFunc = original })
// Stands in for gpg, which the test does not have: "encrypts" by copying
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
return []byte(data.String()), nil
}
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
require.NoError(t, err)
first, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
firstFiles := dirFiles(t, fs, first.GetDirectory())
second, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
}
+2 -2
View File
@@ -193,14 +193,14 @@ func generateSEKeyLabel(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
enrollmentDate := time.Now().UTC().Format("2006-01-02") enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf( return fmt.Sprintf(
"%s.%s-%s-%s", "%s.%s-%s-%s",
seKeyLabelPrefix, seKeyLabelPrefix,
vaultName, vaultName,
hostname, hostname,
enrollmentDate, enrollmentTime,
), nil ), nil
} }
+63 -10
View File
@@ -2,8 +2,10 @@ package vault
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -103,7 +105,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
// resolveUnlockerDirectory reads the current-unlocker file to get the // resolveUnlockerDirectory reads the current-unlocker file to get the
// unlocker directory path // 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) { func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath) secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
@@ -341,7 +343,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return nil 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 // The passphrase must be provided as a LockedBuffer for security
func (v *Vault) CreatePassphraseUnlocker( func (v *Vault) CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer, 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 // 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 // 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() ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get long-term key: %w", err) 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 // Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity() unlockerIdentity, err := age.GenerateX25519Identity()
@@ -379,7 +394,7 @@ func (v *Vault) CreatePassphraseUnlocker(
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: unlockerTypePassphrase, Type: unlockerTypePassphrase,
CreatedAt: time.Now(), CreatedAt: createdAt,
Flags: []string{}, Flags: []string{},
} }
@@ -397,16 +412,54 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, err return nil, err
} }
// Create the unlocker instance // Select the new unlocker by its directory, not by its ID: an old
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) // passphrase unlocker created in the same minute has the same ID.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Select this unlocker as current err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
err = v.SelectUnlocker(unlocker.GetID()) []byte(filepath.Base(unlockerDir)))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err) 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 // readUnlockerMetadata reads and parses the unlocker-metadata.json file in