Give every new unlocker a directory of its own (closes #71)
check / check (push) Failing after 1s
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:
@@ -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/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user