Compare commits

...
3 Commits
Author SHA1 Message Date
sneak 25cc2cbe52 Make an unlocker's ID the name of its directory (closes #98)
check / check (push) Failing after 2s
Keychain and Secure Enclave unlocker IDs were the creation time to the
minute plus the host name, and passphrase unlocker IDs the time to the
minute, so two created within one minute shared an ID, and `unlocker
select`, `unlocker remove` and the selection after `unlocker add` acted on
the older one. Every unlocker's ID is now its directory name, unique in
its vault. `vault.ListUnlockers` returns each unlocker's metadata keyed by
that name, so `unlocker list` and shell completion no longer find IDs by
matching metadata. PGP unlocker IDs were `pgp-<fingerprint>`; a second
PGP unlocker for one key is refused by comparing fingerprints in metadata.

Model: opus-5-5
2026-10-04 19:04:06 +00:00
clawbot 1a23fd3125 Make the README's storage and file format text match the code (closes #102)
check / check (push) Failing after 2s
The directory tree shows `current` and `currentvault` as plain files holding
a name, a version's metadata as the encrypted `metadata.age`, the real state
directory under the user's configuration directory, and the `lock` file.
`version promote` rewrites `current`. File Formats tells unencrypted vault
and unlocker metadata from encrypted version metadata; `pub.age` is plain
text and vault metadata holds no vault name. Unlocker bullets lose Touch ID
claims the code does not set up, and the Secure Enclave only decrypts.
Per-version keys no longer claim forward secrecy. Testing lists only
`make test`.

Model: opus-5-5
2026-10-04 20:58:42 +02:00
clawbot 23dcea83f9 Create a vault whole in a temporary directory, then select it (closes #105)
check / check (push) Failing after 2s
vault.CreateVault takes the unlocker passphrase and writes the vault
directory, its metadata, long-term public key and passphrase unlocker
into a temporary directory, renames that into vaults.d once complete,
and only then makes the vault current. secret init and secret vault
create call it once instead of adding the unlocker afterwards, so a
kill part-way leaves either no vault, whose temporary directory the
next command that takes the lock deletes, or a complete one. A test
records the state directory before every change the call makes and
checks each state, and the command run again from it.

Model: opus-5-5
2026-10-04 20:42:03 +02:00
41 changed files with 653 additions and 624 deletions
+35 -20
View File
@@ -180,8 +180,8 @@ period.
#### `secret version promote <secret-name> <version>`
Promotes a specific version to current by updating the symlink. Does not modify
any timestamps, allowing for rollback scenarios.
Promotes a specific version to current by rewriting the secret's `current` file
to name it. Does not modify any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
@@ -211,7 +211,9 @@ Generates and stores a random secret.
#### `secret unlocker list [--json]` / `secret unlocker ls`
Lists all unlockers in the current vault with their metadata.
Lists all unlockers in the current vault with their metadata. An unlocker's ID,
which `secret unlocker select` and `secret unlocker remove` take, is the name of
its directory in `unlockers.d`.
#### `secret unlocker add <type> [options]`
@@ -278,8 +280,13 @@ Decrypts data using an Age key stored as a secret.
### Directory Structure
The state directory is `berlin.sneak.pkg.secret` in the user's configuration
directory: on Linux `$XDG_CONFIG_HOME`, or `~/.config` when that is unset; on
macOS `~/Library/Application Support`. When `SB_SECRET_STATE_DIR` is set, it is
the state directory instead. On Linux:
```
~/.local/share/secret/
~/.config/berlin.sneak.pkg.secret/
├── vaults.d/
│ ├── default/
│ │ ├── unlockers.d/
@@ -292,12 +299,12 @@ Decrypts data using an Age key stored as a secret.
│ │ │ │ │ │ ├── pub.age # Version public key
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
│ │ │ │ │ │ ├── value.age # Encrypted value
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current -> versions/20231216.001
│ │ │ │ └── current # Current version's name: 20231216.001
│ │ │ └── database%password/ # Secret: database/password
│ │ │ ├── versions/
│ │ │ └── current -> versions/20231215.001
│ │ │ └── current # Current version's name: 20231215.001
│ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker # Current unlocker's directory name
@@ -307,9 +314,13 @@ Decrypts data using an Age key stored as a secret.
│ ├── vault-metadata.json
│ ├── pub.age
│ └── current-unlocker
└── currentvault -> vaults.d/default
├── currentvault # Current vault's name: default
└── lock # Locked by each command that changes anything
```
`current`, `currentvault` and `current-unlocker` are plain files that each hold
one name. Changing one replaces it in one rename, so it is never half-written.
### Key Management and Encryption Flow
#### 1: Long-term Keys
@@ -336,7 +347,7 @@ Unlockers provide different authentication methods to access the long-term keys:
3. **Keychain Unlockers** (macOS only):
- Stores unlock keys in macOS Keychain
- Protected by system authentication (Touch ID, password)
- Kept on this Mac only: the keychain item is never synced to other devices
- Automatic unlocking when Keychain is unlocked
- Cross-application integration
@@ -344,8 +355,10 @@ Unlockers provide different authentication methods to access the long-term keys:
- Hardware-backed key storage using Apple Secure Enclave
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
Program required)
- ECIES encryption: vault long-term key encrypted directly by SE hardware
- Protected by biometric authentication (Touch ID) or system password
- ECIES encryption: the vault long-term key is encrypted directly to the SE
key, and only the SE can decrypt it
- The SE key cannot leave this Mac; using it asks for no Touch ID or
password
Each vault maintains its own set of unlockers and one long-term key. The
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
@@ -355,7 +368,7 @@ access vault secrets.
- Each secret version has its own encryption key pair
- Private key encrypted to the vault's long-term key
- Provides forward secrecy and granular access control
- A version's private key decrypts only that version's value and metadata
### Environment Variables
@@ -505,17 +518,21 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### File Formats
- **age Files**: Standard age encryption format (.age extension)
- **Metadata**: Unencrypted JSON format with timestamps and type information
- **Vault Metadata**: JSON containing vault name, creation time, derivation
index, and public key hash
- **age Files**: Standard age encryption format (.age extension), except
`pub.age`, which holds an age public key as text
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
unencrypted JSON with a creation time, and `unlocker-metadata.json` also
records the unlocker's type; a version's `metadata.age` is JSON encrypted to
the version's public key
- **Vault Metadata**: JSON containing creation time, derivation index, and the
public key hashes described below
### Vault Management
- **Derivation Index**: Each vault uses a unique derivation index from the
mnemonic, and thus a unique key pair
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies
vaults from the same mnemonic
- **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
hash of the index-0 public key identifies vaults from the same mnemonic
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
automatically derived
@@ -566,8 +583,6 @@ The project includes comprehensive tests:
```bash
make test # Run all tests
go test ./... # Unit tests
go test -tags=integration -v ./internal/cli # Integration tests
```
## Entrypoints
+43 -5
View File
@@ -18,6 +18,48 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`,
so no two unlockers of a vault share one
(https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure
Enclave unlocker's ID was its creation time to the minute and the host name,
and a passphrase unlocker's the time to the minute, so two created within a
minute shared an ID, and `unlocker select`, `unlocker remove` and the
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID
was `pgp-` and its key's fingerprint; a second PGP unlocker for a key is
still refused, now by comparing the fingerprint in the other unlockers'
metadata. `unlocker list` and the shell completion of `unlocker select` and
`unlocker remove` take each ID from the directory the unlocker was read
from, no longer by matching metadata, so two unlockers with the same
metadata are listed apart; an unlocker of an unknown type is listed under
its directory name, and completion now offers Secure Enclave unlockers too.
The keychain and Secure Enclave code was type-checked by
`script/lint-darwin`, never run; a test on Linux lists, completes, selects
and removes each of two passphrase unlockers with the same metadata by its
own ID.
- 2026-10-04: README's Storage Architecture, `secret version promote`,
Technical Details and Testing text matches the code
(https://git.eeqj.de/sneak/secret/issues/102). `current` and
`currentvault` are plain files holding a name, not symbolic links; a
version's metadata is the encrypted `metadata.age`; the state directory is
`berlin.sneak.pkg.secret` in the user's configuration directory, not
`~/.local/share/secret`, and holds the `lock` file. Also corrected: the
code sets up no Touch ID for the keychain or Secure Enclave unlocker, and
the Secure Enclave only decrypts; per-version keys give no forward
secrecy; `pub.age` is not age-encrypted; vault metadata holds no vault
name. Testing lists only `make test`.
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or
not at all (https://git.eeqj.de/sneak/secret/issues/105).
`vault.CreateVault` now takes the unlocker passphrase too, writes the vault
directory with its metadata, long-term public key and passphrase unlocker,
`longterm.age` included, into a temporary directory, renames that into
`vaults.d` once it is complete, and only then makes the vault current.
Before, either command killed after the passphrase prompt but before the
unlocker was written left a vault with no unlocker, which `vault create` had
already made current and which neither command would create again. Killed
part-way now, it leaves no vault, and the next command that takes the lock
deletes the temporary directory; or, killed between the rename and making
the vault current, a complete vault that is not current, which
`secret vault select` makes current.
- 2026-10-04: A failed `secret unlocker add keychain` or
`secret unlocker add secure-enclave` no longer leaves its keychain item or
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
@@ -258,11 +300,7 @@ https://git.eeqj.de/sneak/secret/milestone/12
`current-unlocker` never go missing. New versions, new secrets and
cross-vault copies are built in a temporary directory and renamed
into place, and removals rename out of the way first, so a version
or secret is never half-added and never half-removed. An
interrupted command can still leave, from `init` or `vault create`
killed after the passphrase prompt but before the unlocker is
written, a vault with no unlocker, which `vault create` has already
made the current vault.
or secret is never half-added and never half-removed.
- 2026-10-03: The checks run before changing a vault now stop with an
error naming the path and cause when they cannot read what they
inspect, instead of reading the failure as "nothing there": the
+6 -29
View File
@@ -1,10 +1,10 @@
package cli
import (
"path/filepath"
"maps"
"slices"
"strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
@@ -44,7 +44,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
}
// getUnlockerIDsCompletionFunc returns a completion function that provides
// unlocker IDs
// unlocker IDs, the names of the unlockers' directories in unlockers.d
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
@@ -57,38 +57,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get unlocker metadata list
unlockerMetadataList, err := vlt.ListUnlockers()
unlockerMetadata, err := vlt.ListUnlockers()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Collect unlocker IDs
var completions []string
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
for _, metadata := range unlockerMetadataList {
// Get the actual unlocker ID by creating the unlocker instance
id, err := findUnlockerIDByMetadata(
fs, unlockersDir, metadata, false,
)
if err != nil {
secret.Warn(
"Could not read unlockers directory during completion, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
if id != "" && strings.HasPrefix(id, toComplete) {
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
if strings.HasPrefix(id, toComplete) {
completions = append(completions, id)
}
}
+6 -5
View File
@@ -63,10 +63,10 @@ func newConfirmTestVaults(
fs := &afero.MemMapFs{}
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false)
@@ -101,7 +101,8 @@ func newRemoval(t *testing.T, command string) removal {
}
fs, workDir, older := newConfirmTestVaults(t, unlockers)
unlockerID := "pgp-" + listTestGPGKeyID + "A"
// The first unlocker's directory name, written by newConfirmTestVaults
unlockerID := "pgp-0"
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.UnlockersRemove(unlockerID, force, cmd)
@@ -142,7 +143,7 @@ func newRemoval(t *testing.T, command string) removal {
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID +
"' from vault 'work'? It is not the vault's last unlocker.",
}
@@ -150,7 +151,7 @@ func newRemoval(t *testing.T, command string) removal {
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID +
"', the last unlocker of vault 'work', which holds 1 " +
"secret(s)? Without an unlocker the vault opens only " +
+194 -1
View File
@@ -2,7 +2,11 @@ package cli_test
import (
"bytes"
"io"
"maps"
"os"
"slices"
"strings"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
@@ -155,7 +159,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
cmd := &cobra.Command{}
@@ -193,3 +197,192 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
})
}
}
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
// create` killed after the passphrase prompt but before the unlocker was
// written left a vault with no unlocker, which neither command would then
// create again. After the prompt, each command changes the state directory
// only through vault.CreateVault. The test makes that call as the command
// does and records the state directory before each change it makes, and once
// after it returns: what a stop at that point leaves. Each must hold either
// no vault, and not name it current, or exactly the finished vault, which
// opens with the passphrase through its current unlocker. The command run
// again after a stop first takes the lock, which must delete what the stop
// left under a temporary name. Running the command is slow, so it runs once
// on each different state the lock leaves, and must create the vault there,
// or refuse the one there.
//
//nolint:paralleltest // commands on the in-memory filesystem share one lock
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
t.Run("init", func(t *testing.T) {
// From an empty state directory
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
requireStopsLeaveWholeVaultOrNone(t, fs, "default",
"failed to create default vault: vault default already exists",
mnemonic, passphrase,
func(c *cli.Instance) error { return c.Init(cmd) })
})
t.Run("vault create work", func(t *testing.T) {
// From a state directory holding the vault "default"
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
requireStopsLeaveWholeVaultOrNone(t, fs, "work", "vault work already exists",
mnemonic, passphrase,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
})
}
// requireStopsLeaveWholeVaultOrNone checks, as
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
// command run, creating the vault name on fs with mnemonic and passphrase.
// Run again where the vault is there, the command must fail with exists.
func requireStopsLeaveWholeVaultOrNone(
t *testing.T, fs afero.Fs, name, exists string,
mnemonic, passphrase *memguard.LockedBuffer,
run func(c *cli.Instance) error,
) {
t.Helper()
var stops []map[string]string
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
testStateDir, name, mnemonic, passphrase)
require.NoError(t, err)
record()
vaultDir := testStateDir + "/vaults.d/" + name
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
finished := entriesUnder(stops[len(stops)-1], vaultDir)
opener := vault.NewVault(fs, testStateDir, name)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
// Each different state the command run again finds once it holds the lock
var locked []map[string]string
for i, stop := range stops {
if _, there := stop[vaultDir+"/"]; there {
require.Equal(t, finished, entriesUnder(stop, vaultDir),
"stop %d left a partial vault", i)
} else {
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
"stop %d made a missing vault current", i)
}
stopped := newFsFromSnapshot(t, stop)
release, err := vault.LockStateDir(stopped, testStateDir)
require.NoError(t, err)
release()
state := snapshotStateDir(t, stopped)
for path := range state {
require.NotContains(t, path, ".tmp-", "stop %d", i)
}
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
return maps.Equal(s, state)
}) {
locked = append(locked, state)
}
}
for _, state := range locked {
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
if _, there := state[vaultDir+"/"]; there {
require.EqualError(t, run(c), exists)
} else {
require.NoError(t, run(c))
}
}
}
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
// that are under dir.
func entriesUnder(tree map[string]string, dir string) map[string]string {
entries := map[string]string{}
for path, content := range tree {
if strings.HasPrefix(path, dir+"/") {
entries[path] = content
}
}
return entries
}
// hookFs passes every call through to Fs, but first calls before for each
// call that can change the filesystem.
type hookFs struct {
afero.Fs
before func()
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) Create(name string) (afero.File, error) {
h.before()
return h.Fs.Create(name)
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) OpenFile(
name string, flag int, perm os.FileMode,
) (afero.File, error) {
h.before()
return h.Fs.OpenFile(name, flag, perm)
}
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
h.before()
return h.Fs.Mkdir(name, perm)
}
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
h.before()
return h.Fs.MkdirAll(path, perm)
}
func (h hookFs) Remove(name string) error {
h.before()
return h.Fs.Remove(name)
}
func (h hookFs) RemoveAll(path string) error {
h.before()
return h.Fs.RemoveAll(path)
}
func (h hookFs) Rename(oldname, newname string) error {
h.before()
return h.Fs.Rename(oldname, newname)
}
+19 -62
View File
@@ -6,13 +6,10 @@ import (
"log"
"log/slog"
"os"
"path/filepath"
"strings"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39"
@@ -70,43 +67,6 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
}
// setupDefaultVault creates the default vault and derives its long-term
// identity from the mnemonic
func (cli *Instance) setupDefaultVault(
stateDir string, mnemonic *memguard.LockedBuffer,
) (*vault.Vault, *age.X25519Identity, error) {
// Create the default vault - it will handle key derivation internally
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
}
// Get the vault metadata to retrieve the derivation index
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
if err != nil {
secret.Debug("Failed to load vault metadata", "error", err)
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
}
// Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key", "error", err)
return nil, nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
return vlt, ltIdentity, nil
}
// Init initializes the secret manager, holding the state directory lock
// while initialize runs
func (cli *Instance) Init(cmd *cobra.Command) error {
@@ -173,34 +133,31 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
}
defer cleanupPassphrase()
// Create the default vault and derive its long-term key
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
// Create the default vault with its passphrase unlocker
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
mnemonic, passphraseBuffer)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
return fmt.Errorf("failed to create default vault: %w", err)
}
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return err
}
ltPubKey := ltIdentity.Recipient().String()
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
secret.Debug("Failed to create unlocker", "error", err)
return fmt.Errorf("failed to create unlocker: %w", err)
}
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
// private key to longterm.age, so no need to do it again here.
if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n")
cmd.Printf("Long-term public key: %s\n", ltPubKey)
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
cmd.Println("\nYour secret manager is ready to use!")
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
cmd.Println("unlockers are not required for secret operations.")
+1 -1
View File
@@ -2563,7 +2563,7 @@ func secretRmCommand(ctx context.Context, t *testing.T) (*exec.Cmd, string) {
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic)
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic, nil)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
+1 -1
View File
@@ -28,7 +28,7 @@ func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
fs := newTwoVaultFs(t)
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil)
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil, nil)
require.NoError(t, err)
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
+6 -6
View File
@@ -110,7 +110,7 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
{"real", afero.NewOsFs(), t.TempDir()},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
require.NoError(t, err)
// One add creates the secret; the others find that it exists
@@ -185,7 +185,7 @@ func (r *readNotifier) Read(p []byte) (int, error) {
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptPipedIntoAdd(t *testing.T) {
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
@@ -292,14 +292,14 @@ func setupEveryCommand(
mnemonic := testMnemonicBuffer(t)
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
require.NoError(t, err)
otherDir, err := other.GetDirectory()
require.NoError(t, err)
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false)
@@ -487,7 +487,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
@@ -525,7 +525,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptStreamsUnlocked(t *testing.T) {
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
+3 -3
View File
@@ -156,10 +156,10 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
vaultsDir := filepath.Join(stateDir, "vaults.d")
// "default" is created last, so it is the current vault.
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
@@ -205,7 +205,7 @@ func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
fs := afero.NewOsFs()
stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
+1 -1
View File
@@ -68,7 +68,7 @@ func newTwoVaultFs(t *testing.T) afero.Fs {
mnemonic := testMnemonicBuffer(t)
for _, name := range []string{"work", "default"} {
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil)
require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
+2 -1
View File
@@ -71,7 +71,8 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
fs := afero.NewMemMapFs()
// Create vault
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
// Set current vault
+21 -146
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log"
"maps"
"os"
"os/exec"
"path/filepath"
@@ -313,91 +314,8 @@ func newUnlockerSelectCmd() *cobra.Command {
}
}
// unlockerIDFromDir constructs an unlocker of the given metadata type
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
// and, when includeSecureEnclave is false, for secure enclave unlockers.
func unlockerIDFromDir(
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool,
) string {
// Create the appropriate unlocker instance
var unlocker secret.Unlocker
switch metadata.Type {
case unlockerTypePassphrase:
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
case unlockerTypeKeychain:
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
case unlockerTypePGP:
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
case unlockerTypeSecureEnclave:
if includeSecureEnclave {
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
}
}
if unlocker == nil {
return ""
}
return unlocker.GetID()
}
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
// stored metadata matches the given type and creation time and returns
// the matching unlocker's ID. It returns ("", nil) when the directory is
// readable but holds no match, and a non-nil error when the directory
// itself cannot be read. Callers must distinguish the two: an unreadable
// directory means the unlocker's real ID is unknowable, so the entry has
// to be skipped rather than reported under a synthesized ID.
//
// A metadata file that cannot be read or parsed is skipped without a
// warning: every caller gets metadata from vault.ListUnlockers first,
// which has already warned about that directory.
func findUnlockerIDByMetadata(
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool,
) (string, error) {
files, err := afero.ReadDir(fs, unlockersDir)
if err != nil {
return "", fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err,
)
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
err = json.Unmarshal(metadataBytes, &diskMetadata)
if err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type &&
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
includeSecureEnclave), nil
}
}
return "", nil
}
// UnlockersList lists unlockers in the current vault
// UnlockersList lists unlockers in the current vault, each under its ID,
// the name of its directory in unlockers.d
func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
@@ -413,58 +331,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
currentUnlockerID = currentUnlocker.GetID()
}
// Get the metadata first
unlockerMetadataList, err := vlt.ListUnlockers()
unlockerMetadata, err := vlt.ListUnlockers()
if err != nil {
return err
}
// Load actual unlocker objects to get the proper IDs
var unlockers []UnlockerInfo
for _, metadata := range unlockerMetadataList {
// Create unlocker instance to get the proper ID
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory while listing unlockers",
"error", err)
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
metadata := unlockerMetadata[unlockerID]
continue
}
// Find the unlocker directory by type and created time
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
unlockerID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, metadata, true,
)
if err != nil {
secret.Warn("Could not read unlockers directory, skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
// Get the proper ID using the unlocker's ID() method
var properID string
if unlockerID != "" {
properID = unlockerID
} else {
// Generate ID as fallback
properID = fmt.Sprintf("%s-%s",
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
secret.Warn("Could not create unlocker instance, using fallback ID",
"fallback_id", properID, "type", metadata.Type)
}
unlockerInfo := UnlockerInfo{
ID: properID,
unlockers = append(unlockers, UnlockerInfo{
ID: unlockerID,
Type: metadata.Type,
CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags,
IsCurrent: properID == currentUnlockerID,
}
unlockers = append(unlockers, unlockerInfo)
IsCurrent: unlockerID == currentUnlockerID,
})
}
if jsonOutput {
@@ -697,9 +580,7 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
}
// Check if this GPG key is already added
expectedID := "pgp-" + fingerprint
exists, err := cli.checkUnlockerExists(vlt, expectedID)
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
if err != nil {
return fmt.Errorf(
"could not check whether GPG key %s is already an unlocker: %w",
@@ -804,13 +685,7 @@ func (cli *Instance) findUnlockerToRemove(
}
if len(unlockers) == 1 {
lastID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, unlockers[0], true)
if err != nil {
return unlockerToRemove{}, err
}
found.last = lastID == unlockerID
_, found.last = unlockers[unlockerID]
}
// unlockerID may instead name a directory left out of the list. If its
@@ -889,16 +764,16 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID)
}
// checkUnlockerExists reports whether the vault already has an unlocker
// with the given ID. It returns an error, and no answer, when unlockers.d
// or an unlocker's metadata file cannot be read; the caller must then not
// create the unlocker. It reads unlockers.d itself because
// vault.ListUnlockers skips an unlocker it cannot read, which suits
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
// for the GPG key with the given fingerprint. It returns an error, and no
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
// the caller must then not create the unlocker. It reads unlockers.d itself
// because vault.ListUnlockers skips an unlocker it cannot read, which suits
// `unlocker list` but not this check: the skipped unlocker may be the
// duplicate. A directory whose metadata file is missing or corrupt is not
// a working unlocker and is passed over.
func (cli *Instance) checkUnlockerExists(
vlt *vault.Vault, unlockerID string,
func (cli *Instance) pgpUnlockerExists(
vlt *vault.Vault, fingerprint string,
) (bool, error) {
vaultDir, err := vlt.GetDirectory()
if err != nil {
@@ -937,14 +812,14 @@ func (cli *Instance) checkUnlockerExists(
)
}
var metadata secret.UnlockerMetadata
var metadata secret.PGPUnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
continue
}
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
return true, nil
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestAddPGPUnlocker(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret(addTestSecretName,
+2 -2
View File
@@ -46,7 +46,7 @@ func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
fs := newCorruptUnlockerVault(t)
instance, _ := newTestInstance(fs)
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo))
current, err := afero.ReadFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
@@ -72,7 +72,7 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
}{
{
name: "the other unlocker",
unlockerID: "pgp-" + listTestGPGKeyID + "B",
unlockerID: listTestUnlockerDirTwo,
wantLast: true,
wantEntries: []string{listTestUnlockerDirOne},
},
+79
View File
@@ -0,0 +1,79 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
// side by side whose metadata is the same, creation time included, as
// copying an unlocker directory leaves them. It asserts that `unlocker
// list` and the shell completion of `unlocker select` and `unlocker remove`
// give each its own ID, and that each is selected and removed by its ID
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
// get their IDs the same way.
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
dirNames := []string{
"passphrase-2026-10-04.12.30.00.000000000",
"passphrase-2026-10-04.12.30.00.000000000-copy",
}
metadata, err := json.Marshal(secret.UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC),
})
require.NoError(t, err)
for _, dirName := range dirNames {
dir := filepath.Join(unlockersDir, dirName)
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(dir, listTestMetadataFileName), metadata,
listTestFilePerm))
}
listed := listUnlockersJSON(t, fs)
require.Len(t, listed, len(dirNames))
completed, _ := getUnlockerIDsCompletionFunc(fs, listTestStateDir)(
nil, nil, "")
assert.Equal(t, dirNames, completed)
instance, cmd := newTestInstance(fs)
for i, unlocker := range listed {
assert.Equal(t, dirNames[i], unlocker.ID)
require.NoError(t, instance.UnlockerSelect(unlocker.ID))
current, err := afero.ReadFile(fs,
filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
assert.Equal(t, dirNames[i], string(current))
}
// The second one first: an ID both shared would remove the first one
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
assertDirEntries(t, fs, unlockersDir, dirNames[0])
require.NoError(t, instance.UnlockersRemove(listed[0].ID, true, cmd))
assertDirEntries(t, fs, unlockersDir)
}
+25 -80
View File
@@ -1,25 +1,13 @@
// Unlocker List Tests
//
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
// or an unlocker's metadata in it, cannot be read while the listing is
// being rendered:
// Tests for `secret unlocker list` behavior when an unlocker's metadata
// cannot be read or used:
//
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
// still listed, with its real ID and its current-unlocker marker,
// when a later entry's scan fails.
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
// metadata does not stop the others from being listed.
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
// file cannot be checked for or read is left out, and the other is
// still listed.
//
// The listing resolves each unlocker's real ID by rescanning unlockers.d
// after the vault has already enumerated it. If that rescan fails the ID
// is unknowable, so the entry must be skipped: a synthesized ID matches
// no `unlocker remove` or `unlocker select` argument and would also
// suppress the current-unlocker marker.
//nolint:testpackage // white-box test of unexported internals
package cli
@@ -48,18 +36,16 @@ const (
// listTestVaultName is the name of that synthetic vault.
listTestVaultName = "default"
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
// unlocker's metadata. The unlocker's real ID is derived from it, and
// differs from the timestamp-derived fallback ID.
// listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
// in the PGP unlockers' metadata.
listTestGPGKeyID = "DEADBEEFDEADBEEF"
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
// directory names under unlockers.d.
// directory names under unlockers.d, and so the unlockers' IDs.
listTestUnlockerDirOne = "host-pgp-2026-08-09"
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
// listTestUnlockersDirName is the directory the listing rescans to
// resolve unlocker IDs.
// listTestUnlockersDirName is the directory holding the unlockers.
listTestUnlockersDirName = "unlockers.d"
// listTestMetadataFileName is the per-unlocker metadata file name.
@@ -74,25 +60,16 @@ const (
// a successful open of unlockers.d.
var errUnlockersDirUnreadable = errors.New("permission denied")
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
// successfully openBudget times. This reproduces the directory becoming
// unreadable (permission change, partially restored backup, EIO) between
// the vault's own enumeration and the per-entry rescan that resolves
// unlocker IDs.
// unlockersDirFailFs fails every open of unlockers.d, as when the
// directory cannot be read.
type unlockersDirFailFs struct {
afero.Fs
openBudget int
opens int
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == listTestUnlockersDirName {
f.opens++
if f.opens > f.openBudget {
return nil, errUnlockersDirUnreadable
}
return nil, errUnlockersDirUnreadable
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
@@ -142,8 +119,8 @@ func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
return f.Fs.Stat(name)
}
// writePGPUnlocker writes a PGP unlocker directory with metadata that
// yields the real ID "pgp-<keyID>".
// writePGPUnlocker writes a PGP unlocker directory named dirName, with
// metadata recording the GPG key ID keyID.
func writePGPUnlocker(
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
createdAt time.Time, keyID string,
@@ -224,44 +201,6 @@ func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
return decoded.Unlockers
}
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
// which becomes unreadable after the vault enumerated it produces no rows,
// rather than rows carrying fabricated fallback IDs.
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
// Budget of one: the vault's own ListUnlockers scan succeeds, the
// per-entry rescan that resolves the ID fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
unlockers := listUnlockersJSON(t, fs)
assert.Empty(t, unlockers,
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
}
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
// entry survives with its real ID and current-unlocker marker when a later
// entry's rescan fails.
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
// the second entry's rescan fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, 1,
"only the entry whose directory was readable may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
"the surviving row must carry the real unlocker ID")
assert.True(t, unlockers[0].IsCurrent,
"the current-unlocker marker must survive the skip")
}
// TestUnlockersListReadableEntriesAreListed is the control case: with a
// fully readable unlockers.d every entry is listed with its real ID.
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
@@ -272,20 +211,21 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
unlockers := listUnlockersJSON(t, base)
require.Len(t, unlockers, 2)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
assert.True(t, unlockers[0].IsCurrent)
assert.False(t, unlockers[1].IsCurrent)
}
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
// corrupt metadata does not stop the listing. Metadata that is not JSON
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
// leaves that unlocker out; PGP metadata without a usable GPG key ID, and
// metadata of an unknown type, are still listed, under the directory name
// like any other. The healthy unlocker is listed with its real ID.
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
t.Parallel()
healthyID := "pgp-" + listTestGPGKeyID + "A"
healthyID := listTestUnlockerDirOne
tests := []struct {
name string
@@ -300,12 +240,17 @@ func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
{
name: "GPG key ID of the wrong type",
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
wantIDs: []string{healthyID, "pgp-unknown"},
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
{
name: "GPG key ID missing",
metadata: `{"type": "pgp"}`,
wantIDs: []string{healthyID, "pgp-unknown"},
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
{
name: "unknown type",
metadata: `{"type": "unknown"}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
}
@@ -371,7 +316,7 @@ func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
require.Len(t, unlockers, 1,
"only the unlocker with usable metadata may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
"the listed row must carry the real unlocker ID")
})
}
+1 -1
View File
@@ -290,7 +290,7 @@ func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
writeTestSecret(t, base, vaultDir)
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
_, err := instance.findUnlockerToRemove("pgp-" + listTestGPGKeyID + "A")
_, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.ErrorIs(t, err, errStatFailed)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
+8 -22
View File
@@ -293,40 +293,26 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
}
defer cleanupPassphrase()
// Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
// Create the vault with its passphrase unlocker
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
mnemonic, passphraseBuffer)
if err != nil {
return err
}
// Get the vault metadata to retrieve the derivation index
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to load vault metadata: %w", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
// Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
return fmt.Errorf("failed to create unlocker: %w", err)
return err
}
cmd.Printf("Created vault '%s'\n", vlt.GetName())
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
return nil
}
+2 -1
View File
@@ -73,7 +73,8 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper()
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
vlt, err := vault.CreateVault(fs, testStateDir, "default",
testMnemonicBuffer(t), nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
+6 -6
View File
@@ -236,7 +236,7 @@ func newVaultWithSecret(
) *vault.Vault {
t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value))
@@ -353,7 +353,7 @@ func TestLongestNames(t *testing.T) {
fs := afero.NewOsFs()
name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long"))
@@ -647,7 +647,7 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// No mnemonic, and no current unlocker to get the key from
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -679,7 +679,7 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
@@ -728,7 +728,7 @@ func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
require.NoError(t, err)
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
@@ -911,7 +911,7 @@ func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic)
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
require.NoError(t, err)
// The unlocker's directory is named se-<label of its Secure Enclave key>
+2 -13
View File
@@ -156,20 +156,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory
}
// GetID implements Unlocker interface - generates ID from keychain item name
// GetID implements Unlocker interface: the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string {
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
// This matches the passphrase unlocker format
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
// Use the creation timestamp from metadata
createdAt := k.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
return filepath.Base(k.Directory)
}
// Remove implements Unlocker interface - removes the keychain unlocker
+3 -2
View File
@@ -4,6 +4,7 @@ package secret
import (
"errors"
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
@@ -60,9 +61,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory
}
// GetID returns the unlocker ID
// GetID returns the unlocker ID, the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string {
return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain"
return filepath.Base(k.Directory)
}
// GetKeychainItemName returns an error on non-Darwin platforms
+2 -5
View File
@@ -109,12 +109,9 @@ func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements Unlocker interface - generates ID from creation timestamp
// GetID implements Unlocker interface: the name of the unlocker's directory
func (p *PassphraseUnlocker) GetID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
createdAt := p.Metadata.CreatedAt
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
return filepath.Base(p.Directory)
}
// Remove implements Unlocker interface - removes the passphrase unlocker
+5 -55
View File
@@ -297,11 +297,6 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Create a PGP unlocker for the remaining tests
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
// Test getting GPG key ID
t.Run("GetGPGKeyID", func(t *testing.T) {
testGetGPGKeyID(t, fs, unlocker, unlockerDir, metadata, fingerprint)
})
// Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) {
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
@@ -330,7 +325,7 @@ func testCreatePGPUnlocker(
mnemonic := testMnemonicBuffer(t)
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -396,10 +391,10 @@ func testCreatePGPUnlocker(
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// Check if the key ID includes the GPG fingerprint
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'",
pgpUnlocker.GetID(), fingerprint)
// Check that the ID is the name of the unlocker's directory
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory()))
}
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
@@ -504,51 +499,6 @@ func checkPGPUnlockerMetadata(
}
}
// testGetGPGKeyID writes PGP unlocker metadata holding the GPG fingerprint
// into unlockerDir and checks that unlocker reads it back.
func testGetGPGKeyID(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir string, metadata secret.UnlockerMetadata, fingerprint string,
) {
t.Helper()
// Create PGP metadata with GPG key ID
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpgKeyId"`
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: fingerprint,
}
// Write metadata file
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write metadata: %v", err)
}
// Get GPG key ID
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
// Verify key ID (should be the fingerprint)
if retrievedKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
}
}
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
// keyID into unlockerDir and checks that unlocker decrypts it.
func testPGPUnlockerGetIdentity(
+2 -38
View File
@@ -155,21 +155,9 @@ func (p *PGPUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements Unlocker interface - generates ID from GPG key ID.
// If the metadata has no usable GPG key ID, it warns with the unlocker's
// directory and returns "pgp-unknown", so listing the other unlockers
// still works.
// GetID implements Unlocker interface: the name of the unlocker's directory
func (p *PGPUnlocker) GetID() string {
// Generate ID using GPG key ID: pgp-<keyid>
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
"directory", p.Directory, "error", err)
return "pgp-unknown"
}
return "pgp-" + gpgKeyID
return filepath.Base(p.Directory)
}
// Remove implements Unlocker interface - removes the PGP unlocker
@@ -184,30 +172,6 @@ func (p *PGPUnlocker) Remove() error {
return nil
}
// GetGPGKeyID returns the GPG key ID from metadata
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
// Load the metadata
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(p.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
}
var pgpMetadata PGPUnlockerMetadata
err = json.Unmarshal(metadataData, &pgpMetadata)
if err != nil {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
}
if pgpMetadata.GPGKeyID == "" {
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
}
return pgpMetadata.GPGKeyID, nil
}
// generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and time
func generatePGPUnlockerName() (string, error) {
+2 -2
View File
@@ -48,7 +48,7 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
installFakeGPG(t)
base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -87,7 +87,7 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
require.NoError(t, err)
first, err := secret.CreatePGPUnlocker(
+2 -10
View File
@@ -130,17 +130,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory
}
// GetID implements Unlocker interface.
// GetID implements Unlocker interface: the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
createdAt := s.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
return filepath.Base(s.Directory)
}
// Remove implements Unlocker interface.
+3 -2
View File
@@ -4,6 +4,7 @@ package secret
import (
"errors"
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
@@ -67,9 +68,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory
}
// GetID returns the unlocker ID.
// GetID returns the unlocker ID, the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string {
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
return filepath.Base(s.Directory)
}
// Remove returns an error on non-Darwin platforms.
+2 -3
View File
@@ -35,9 +35,8 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
// Test GetDirectory returns the directory we passed in
assert.Equal(t, dir, unlocker.GetDirectory())
// Test GetID returns a formatted string with the creation timestamp
expectedID := "2026-01-15.10.30-secure-enclave"
assert.Equal(t, expectedID, unlocker.GetID())
// Test GetID returns the name of the unlocker's directory
assert.Equal(t, "test-se-unlocker", unlocker.GetID())
}
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
+2 -4
View File
@@ -61,11 +61,9 @@ func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
id := unlocker.GetID()
// ID should contain the timestamp and "secure-enclave" type
assert.Contains(t, id, "2026-03-10.14.30")
assert.Contains(t, id, seUnlockerType)
// The ID is the name of the unlocker's directory
assert.Equal(t, "test", unlocker.GetID())
}
func TestGenerateSEKeyLabel(t *testing.T) {
+1 -1
View File
@@ -10,6 +10,6 @@ type Unlocker interface {
GetType() string
GetMetadata() UnlockerMetadata
GetDirectory() string
GetID() string // Generate ID based on unlocker type and data
GetID() string // The name of the unlocker's directory, unique in its vault
Remove() error // Remove the unlocker and any associated resources
}
+5
View File
@@ -31,6 +31,11 @@ var (
// Composed as "vault <name> already exists".
ErrVaultExists = errors.New("already exists")
// ErrUnlockerWithoutMnemonic indicates that CreateVault was given a
// passphrase for an unlocker but no mnemonic to derive the long-term key
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
// ErrNilValueBuffer indicates a nil value buffer was supplied.
ErrNilValueBuffer = errors.New("value buffer is nil")
+8 -8
View File
@@ -99,7 +99,7 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
// Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -147,7 +147,7 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -223,7 +223,7 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -324,7 +324,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
}
for _, name := range validNames {
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err != nil {
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
}
@@ -340,7 +340,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
}
for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err == nil {
t.Errorf("Expected error creating vault with invalid name %q, "+
"but got none", name)
@@ -361,7 +361,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
// Create three vaults
vaultNames := []string{"vault1", "vault2", "vault3"}
for _, name := range vaultNames {
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault %s: %v", name, err)
}
@@ -411,12 +411,12 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
// Create two vaults - CreateVault writes the public key derived from
// the mnemonic
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault1: %v", err)
}
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault2: %v", err)
}
+1 -1
View File
@@ -49,7 +49,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs()
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil)
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
+75 -53
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
@@ -152,16 +153,17 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
}
// processMnemonicForVault handles mnemonic processing for vault creation.
// It returns the derivation index, public key hash, and family hash.
// It returns the long-term key, nil when there is no mnemonic, and the
// derivation index, public key hash, and family hash.
func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer,
) (uint32, string, string, error) {
) (*age.X25519Identity, uint32, string, string, error) {
if mnemonicBuffer == nil {
secret.Debug("No mnemonic given, vault created without long-term key",
"vault", vaultName)
// Use 0 for derivation index when no mnemonic is provided
return 0, "", "", nil
return nil, 0, "", "", nil
}
mnemonic := mnemonicBuffer.String()
@@ -171,13 +173,14 @@ func processMnemonicForVault(
// Get the next available derivation index for this mnemonic
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
if err != nil {
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
return nil, 0, "", "",
fmt.Errorf("failed to get next derivation index: %w", err)
}
// Derive the long-term key using the actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
if err != nil {
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
return nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
}
// Write the public key
@@ -187,7 +190,8 @@ func processMnemonicForVault(
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
if err != nil {
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
return nil, 0, "", "",
fmt.Errorf("failed to write long-term public key: %w", err)
}
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
@@ -199,24 +203,33 @@ func processMnemonicForVault(
// This is used to identify which vaults belong to the same mnemonic family
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
return nil, 0, "", "",
fmt.Errorf("failed to derive identity for index 0: %w", err)
}
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
return derivationIndex, publicKeyHash, familyHash, nil
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
}
// CreateVault creates a new vault and selects it as the current vault. When
// mnemonic is not nil, the vault's long-term key is derived from it, and the
// returned vault has it as its Mnemonic; when it is nil, the vault has no
// long-term key until one is imported. It refuses a vault that already
// exists before writing anything: creating it again would replace its keys,
// and its secrets could no longer be decrypted. The commands that call it
// hold the state directory lock, so no other command can create the vault
// between the check and the writes.
// long-term key until one is imported. When passphrase is not nil, the vault
// gets a passphrase unlocker protected by it, as its current unlocker; that
// needs a mnemonic. It refuses a vault that already exists before writing
// anything: creating it again would replace its keys, and its secrets could
// no longer be decrypted. The commands that call it hold the state directory
// lock, so no other command can create the vault between the check and the
// writes.
//
// The vault is written whole into a temporary directory, which is renamed
// into vaults.d only once complete, and only then selected: a crash at any
// point leaves either no vault or a complete one. The next command that
// takes the lock deletes what the crash left under a temporary name.
func CreateVault(
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
fs afero.Fs, stateDir string, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
@@ -240,51 +253,19 @@ func CreateVault(
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
}
// Create vault directory structure
if passphrase != nil && mnemonic == nil {
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
}
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
// Create main vault directory
err = fs.MkdirAll(vaultDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create vault directory: %w", err)
}
// Create secrets directory
secretsDir := filepath.Join(vaultDir, "secrets.d")
err = fs.MkdirAll(secretsDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
}
// Create unlockers directory
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
}
// Process mnemonic if available
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
fs, stateDir, vaultDir, name, mnemonic)
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
})
if err != nil {
return nil, err
}
// Save vault metadata
metadata := &Metadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
err = SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
}
// Select the newly created vault as current
secret.Debug("Selecting newly created vault as current", "name", name)
@@ -302,6 +283,47 @@ func CreateVault(
return vlt, nil
}
// writeVaultFiles writes the files of the new vault name into vaultDir: its
// secrets and unlockers directories, its long-term public key and metadata,
// and, when passphrase is not nil, a passphrase unlocker as its current one.
func writeVaultFiles(
fs afero.Fs, stateDir, vaultDir, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) error {
for _, subdir := range []string{"secrets.d", "unlockers.d"} {
err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms)
if err != nil {
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
}
}
ltIdentity, derivationIndex, publicKeyHash, familyHash, err :=
processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic)
if err != nil {
return err
}
metadata := &Metadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
err = SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
return fmt.Errorf("failed to save vault metadata: %w", err)
}
if passphrase == nil {
return nil
}
_, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase)
return err
}
// SelectVault selects the given vault as the current vault
func SelectVault(fs afero.Fs, stateDir string, name string) error {
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
+2 -2
View File
@@ -304,7 +304,7 @@ func TestWorkflowMismatch(t *testing.T) {
fs := afero.NewOsFs()
// Test Case 1: Create vault WITH mnemonic (like init command)
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault with mnemonic: %v", err)
}
@@ -321,7 +321,7 @@ func TestWorkflowMismatch(t *testing.T) {
metadata1.DerivationIndex, metadata1.PublicKeyHash)
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
_, err = vault.CreateVault(fs, tempDir, "work", nil)
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
if err != nil {
t.Fatalf("Failed to create vault without mnemonic: %v", err)
}
+3 -3
View File
@@ -18,7 +18,7 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
require.NoError(t, err)
// Add a legitimate secret so the vault is set up
@@ -57,7 +57,7 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd")
@@ -73,7 +73,7 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
require.NoError(t, err)
maliciousNames := []string{
+1 -1
View File
@@ -66,7 +66,7 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper()
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil)
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
+41 -25
View File
@@ -188,8 +188,9 @@ func (v *Vault) findUnlockerByID(
return nil, skippedDirPath, nil
}
// ListUnlockers returns a list of available unlockers for this vault
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
// ListUnlockers returns the metadata of each unlocker of this vault, keyed
// by the unlocker's ID, the name of its directory in unlockers.d
func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
@@ -204,7 +205,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
}
if !exists {
return []UnlockerMetadata{}, nil
return map[string]UnlockerMetadata{}, nil
}
// List directories in unlockers.d
@@ -213,7 +214,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
}
var unlockers []UnlockerMetadata
unlockers := map[string]UnlockerMetadata{}
for _, file := range files {
if !file.IsDir() {
@@ -222,7 +223,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok {
unlockers = append(unlockers, metadata)
unlockers[file.Name()] = metadata
}
}
@@ -390,8 +391,31 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, err
}
unlocker, err := writePassphraseUnlocker(v.fs, vaultDir, ltIdentity, passphrase)
if err != nil {
return nil, err
}
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 unlocker, nil
}
// writePassphraseUnlocker writes a new passphrase unlocker of the long-term
// key ltIdentity into the vault directory vaultDir, in a directory of its own,
// and makes it the vault's current unlocker.
func writePassphraseUnlocker(
fs afero.Fs, vaultDir string, ltIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) {
createdAt := time.Now()
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase+"-"+
createdAt.UTC().Format(secret.UnlockerTimeFormat))
// Generate new age keypair for unlocker
@@ -422,33 +446,24 @@ func (v *Vault) CreatePassphraseUnlocker(
}
// Write the unlocker's files, the metadata last
err = secret.WriteDir(v.fs, unlockerDir, func(dir string) error {
return v.writeUnlockerFiles(dir, unlockerIdentity, passphrase,
err = secret.WriteDir(fs, unlockerDir, func(dir string) error {
return writeUnlockerFiles(fs, dir, unlockerIdentity, passphrase,
encryptedLtPrivKey, metadataBytes)
})
if err != nil {
return nil, err
}
// Select the new unlocker by its directory, not by its ID: an old
// passphrase unlocker created in the same minute has the same ID.
// Make the new unlocker the current one
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
err = secret.WriteFileAtomic(fs, currentUnlockerPath,
[]byte(filepath.Base(unlockerDir)))
if err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
}
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
return secret.NewPassphraseUnlocker(fs, unlockerDir, metadata), nil
}
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
@@ -514,7 +529,8 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
// writeUnlockerFiles writes the files of a passphrase unlocker into
// unlockerDir: its public key, its passphrase-encrypted private key, the
// long-term private key encrypted to it, and its metadata, last.
func (v *Vault) writeUnlockerFiles(
func writeUnlockerFiles(
fs afero.Fs,
unlockerDir string,
unlockerIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
@@ -523,7 +539,7 @@ func (v *Vault) writeUnlockerFiles(
// Write public key
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
err := secret.WriteFileAtomic(v.fs, pubKeyPath,
err := secret.WriteFileAtomic(fs, pubKeyPath,
[]byte(unlockerIdentity.Recipient().String()))
if err != nil {
return fmt.Errorf("failed to write unlocker public key: %w", err)
@@ -541,18 +557,18 @@ func (v *Vault) writeUnlockerFiles(
// Write encrypted private key
privKeyPath := filepath.Join(unlockerDir, "priv.age")
err = secret.WriteFileAtomic(v.fs, privKeyPath, encryptedPrivKey)
err = secret.WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
}
err = secret.WriteFileAtomic(v.fs,
err = secret.WriteFileAtomic(fs,
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = secret.WriteFileAtomic(v.fs,
err = secret.WriteFileAtomic(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
+29 -2
View File
@@ -2,6 +2,7 @@ package vault_test
import (
"bytes"
"errors"
"path/filepath"
"slices"
"testing"
@@ -72,7 +73,7 @@ func testCreateVault(t *testing.T, fs afero.Fs) {
t.Helper()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -298,7 +299,7 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -344,3 +345,29 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
}
}
}
// TestCreateVaultUnlockerNeedsMnemonic checks that CreateVault, given a
// passphrase for an unlocker but no mnemonic to derive the long-term key from,
// fails without writing anything.
func TestCreateVaultUnlockerNeedsMnemonic(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
_, err := vault.CreateVault(fs, testStateDir, testVaultName, nil, passphrase)
if !errors.Is(err, vault.ErrUnlockerWithoutMnemonic) {
t.Fatalf("Expected ErrUnlockerWithoutMnemonic, got %v", err)
}
exists, err := afero.Exists(fs, testStateDir)
if err != nil {
t.Fatalf("Failed to check for the state directory: %v", err)
}
if exists {
t.Errorf("CreateVault wrote the state directory")
}
}