Make an unlocker's ID the name of its directory (closes #98)
check / check (push) Failing after 2s
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
This commit is contained in:
@@ -211,7 +211,9 @@ Generates and stores a random secret.
|
|||||||
|
|
||||||
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
#### `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]`
|
#### `secret unlocker add <type> [options]`
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
|
|
||||||
# Completed Steps
|
# 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`,
|
- 2026-10-04: README's Storage Architecture, `secret version promote`,
|
||||||
Technical Details and Testing text matches the code
|
Technical Details and Testing text matches the code
|
||||||
(https://git.eeqj.de/sneak/secret/issues/102). `current` and
|
(https://git.eeqj.de/sneak/secret/issues/102). `current` and
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"maps"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -44,7 +44,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
// 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(
|
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
@@ -57,38 +57,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get unlocker metadata list
|
unlockerMetadata, err := vlt.ListUnlockers()
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vault directory
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect unlocker IDs
|
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||||
|
if strings.HasPrefix(id, toComplete) {
|
||||||
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) {
|
|
||||||
completions = append(completions, id)
|
completions = append(completions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ func newRemoval(t *testing.T, command string) removal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs, workDir, older := newConfirmTestVaults(t, unlockers)
|
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 {
|
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||||
return cli.UnlockersRemove(unlockerID, force, cmd)
|
return cli.UnlockersRemove(unlockerID, force, cmd)
|
||||||
@@ -142,7 +143,7 @@ func newRemoval(t *testing.T, command string) removal {
|
|||||||
return removal{
|
return removal{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
run: removeFirstUnlocker,
|
run: removeFirstUnlocker,
|
||||||
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
|
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
||||||
question: "Permanently remove unlocker '" + unlockerID +
|
question: "Permanently remove unlocker '" + unlockerID +
|
||||||
"' from vault 'work'? It is not the vault's last unlocker.",
|
"' 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{
|
return removal{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
run: removeFirstUnlocker,
|
run: removeFirstUnlocker,
|
||||||
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
|
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
||||||
question: "Permanently remove unlocker '" + unlockerID +
|
question: "Permanently remove unlocker '" + unlockerID +
|
||||||
"', the last unlocker of vault 'work', which holds 1 " +
|
"', the last unlocker of vault 'work', which holds 1 " +
|
||||||
"secret(s)? Without an unlocker the vault opens only " +
|
"secret(s)? Without an unlocker the vault opens only " +
|
||||||
|
|||||||
+21
-146
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -313,91 +314,8 @@ func newUnlockerSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
// UnlockersList lists unlockers in the current vault, each under its ID,
|
||||||
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
// the name of its directory in unlockers.d
|
||||||
// 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
|
|
||||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
@@ -413,58 +331,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
currentUnlockerID = currentUnlocker.GetID()
|
currentUnlockerID = currentUnlocker.GetID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the metadata first
|
unlockerMetadata, err := vlt.ListUnlockers()
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load actual unlocker objects to get the proper IDs
|
|
||||||
var unlockers []UnlockerInfo
|
var unlockers []UnlockerInfo
|
||||||
|
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||||
// Create unlocker instance to get the proper ID
|
metadata := unlockerMetadata[unlockerID]
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not get vault directory while listing unlockers",
|
|
||||||
"error", err)
|
|
||||||
|
|
||||||
continue
|
unlockers = append(unlockers, UnlockerInfo{
|
||||||
}
|
ID: unlockerID,
|
||||||
|
|
||||||
// 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,
|
|
||||||
Type: metadata.Type,
|
Type: metadata.Type,
|
||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Flags: metadata.Flags,
|
Flags: metadata.Flags,
|
||||||
IsCurrent: properID == currentUnlockerID,
|
IsCurrent: unlockerID == currentUnlockerID,
|
||||||
}
|
})
|
||||||
unlockers = append(unlockers, unlockerInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -697,9 +580,7 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this GPG key is already added
|
// Check if this GPG key is already added
|
||||||
expectedID := "pgp-" + fingerprint
|
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
|
||||||
|
|
||||||
exists, err := cli.checkUnlockerExists(vlt, expectedID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"could not check whether GPG key %s is already an unlocker: %w",
|
"could not check whether GPG key %s is already an unlocker: %w",
|
||||||
@@ -804,13 +685,7 @@ func (cli *Instance) findUnlockerToRemove(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(unlockers) == 1 {
|
if len(unlockers) == 1 {
|
||||||
lastID, err := findUnlockerIDByMetadata(
|
_, found.last = unlockers[unlockerID]
|
||||||
cli.fs, unlockersDir, unlockers[0], true)
|
|
||||||
if err != nil {
|
|
||||||
return unlockerToRemove{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
found.last = lastID == unlockerID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockerID may instead name a directory left out of the list. If its
|
// 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)
|
return vlt.SelectUnlocker(unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkUnlockerExists reports whether the vault already has an unlocker
|
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
|
||||||
// with the given ID. It returns an error, and no answer, when unlockers.d
|
// for the GPG key with the given fingerprint. It returns an error, and no
|
||||||
// or an unlocker's metadata file cannot be read; the caller must then not
|
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
|
||||||
// create the unlocker. It reads unlockers.d itself because
|
// the caller must then not create the unlocker. It reads unlockers.d itself
|
||||||
// vault.ListUnlockers skips an unlocker it cannot read, which suits
|
// because vault.ListUnlockers skips an unlocker it cannot read, which suits
|
||||||
// `unlocker list` but not this check: the skipped unlocker may be the
|
// `unlocker list` but not this check: the skipped unlocker may be the
|
||||||
// duplicate. A directory whose metadata file is missing or corrupt is not
|
// duplicate. A directory whose metadata file is missing or corrupt is not
|
||||||
// a working unlocker and is passed over.
|
// a working unlocker and is passed over.
|
||||||
func (cli *Instance) checkUnlockerExists(
|
func (cli *Instance) pgpUnlockerExists(
|
||||||
vlt *vault.Vault, unlockerID string,
|
vlt *vault.Vault, fingerprint string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -937,14 +812,14 @@ func (cli *Instance) checkUnlockerExists(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata secret.UnlockerMetadata
|
var metadata secret.PGPUnlockerMetadata
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
|
if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
|
|||||||
fs := newCorruptUnlockerVault(t)
|
fs := newCorruptUnlockerVault(t)
|
||||||
instance, _ := newTestInstance(fs)
|
instance, _ := newTestInstance(fs)
|
||||||
|
|
||||||
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
|
require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo))
|
||||||
|
|
||||||
current, err := afero.ReadFile(fs,
|
current, err := afero.ReadFile(fs,
|
||||||
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
||||||
@@ -72,7 +72,7 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "the other unlocker",
|
name: "the other unlocker",
|
||||||
unlockerID: "pgp-" + listTestGPGKeyID + "B",
|
unlockerID: listTestUnlockerDirTwo,
|
||||||
wantLast: true,
|
wantLast: true,
|
||||||
wantEntries: []string{listTestUnlockerDirOne},
|
wantEntries: []string{listTestUnlockerDirOne},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,25 +1,13 @@
|
|||||||
// Unlocker List Tests
|
// Unlocker List Tests
|
||||||
//
|
//
|
||||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
// Tests for `secret unlocker list` behavior when an unlocker's metadata
|
||||||
// or an unlocker's metadata in it, cannot be read while the listing is
|
// cannot be read or used:
|
||||||
// being rendered:
|
|
||||||
//
|
//
|
||||||
// - 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
|
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||||
// metadata does not stop the others from being listed.
|
// metadata does not stop the others from being listed.
|
||||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||||
// file cannot be checked for or read is left out, and the other is
|
// file cannot be checked for or read is left out, and the other is
|
||||||
// still listed.
|
// 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
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
@@ -48,18 +36,16 @@ const (
|
|||||||
// listTestVaultName is the name of that synthetic vault.
|
// listTestVaultName is the name of that synthetic vault.
|
||||||
listTestVaultName = "default"
|
listTestVaultName = "default"
|
||||||
|
|
||||||
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
|
// listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
|
||||||
// unlocker's metadata. The unlocker's real ID is derived from it, and
|
// in the PGP unlockers' metadata.
|
||||||
// differs from the timestamp-derived fallback ID.
|
|
||||||
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
||||||
|
|
||||||
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
// 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"
|
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
||||||
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
||||||
|
|
||||||
// listTestUnlockersDirName is the directory the listing rescans to
|
// listTestUnlockersDirName is the directory holding the unlockers.
|
||||||
// resolve unlocker IDs.
|
|
||||||
listTestUnlockersDirName = "unlockers.d"
|
listTestUnlockersDirName = "unlockers.d"
|
||||||
|
|
||||||
// listTestMetadataFileName is the per-unlocker metadata file name.
|
// listTestMetadataFileName is the per-unlocker metadata file name.
|
||||||
@@ -74,25 +60,16 @@ const (
|
|||||||
// a successful open of unlockers.d.
|
// a successful open of unlockers.d.
|
||||||
var errUnlockersDirUnreadable = errors.New("permission denied")
|
var errUnlockersDirUnreadable = errors.New("permission denied")
|
||||||
|
|
||||||
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
|
// unlockersDirFailFs fails every open of unlockers.d, as when the
|
||||||
// successfully openBudget times. This reproduces the directory becoming
|
// directory cannot be read.
|
||||||
// unreadable (permission change, partially restored backup, EIO) between
|
|
||||||
// the vault's own enumeration and the per-entry rescan that resolves
|
|
||||||
// unlocker IDs.
|
|
||||||
type unlockersDirFailFs struct {
|
type unlockersDirFailFs struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
|
|
||||||
openBudget int
|
|
||||||
opens int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||||
if filepath.Base(name) == listTestUnlockersDirName {
|
if filepath.Base(name) == listTestUnlockersDirName {
|
||||||
f.opens++
|
return nil, errUnlockersDirUnreadable
|
||||||
if f.opens > f.openBudget {
|
|
||||||
return nil, errUnlockersDirUnreadable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
//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)
|
return f.Fs.Stat(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
// writePGPUnlocker writes a PGP unlocker directory named dirName, with
|
||||||
// yields the real ID "pgp-<keyID>".
|
// metadata recording the GPG key ID keyID.
|
||||||
func writePGPUnlocker(
|
func writePGPUnlocker(
|
||||||
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
||||||
createdAt time.Time, keyID string,
|
createdAt time.Time, keyID string,
|
||||||
@@ -224,44 +201,6 @@ func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
|
|||||||
return decoded.Unlockers
|
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
|
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
||||||
// fully readable unlockers.d every entry is listed with its real ID.
|
// fully readable unlockers.d every entry is listed with its real ID.
|
||||||
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||||
@@ -272,20 +211,21 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
|||||||
unlockers := listUnlockersJSON(t, base)
|
unlockers := listUnlockersJSON(t, base)
|
||||||
|
|
||||||
require.Len(t, unlockers, 2)
|
require.Len(t, unlockers, 2)
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
|
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
|
assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
|
||||||
assert.True(t, unlockers[0].IsCurrent)
|
assert.True(t, unlockers[0].IsCurrent)
|
||||||
assert.False(t, unlockers[1].IsCurrent)
|
assert.False(t, unlockers[1].IsCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
||||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
// 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
|
// leaves that unlocker out; PGP metadata without a usable GPG key ID, and
|
||||||
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
// 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) {
|
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
healthyID := "pgp-" + listTestGPGKeyID + "A"
|
healthyID := listTestUnlockerDirOne
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -300,12 +240,17 @@ func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "GPG key ID of the wrong type",
|
name: "GPG key ID of the wrong type",
|
||||||
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
||||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GPG key ID missing",
|
name: "GPG key ID missing",
|
||||||
metadata: `{"type": "pgp"}`,
|
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,
|
require.Len(t, unlockers, 1,
|
||||||
"only the unlocker with usable metadata may be listed")
|
"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")
|
"the listed row must carry the real unlocker ID")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
|
|||||||
writeTestSecret(t, base, vaultDir)
|
writeTestSecret(t, base, vaultDir)
|
||||||
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
|
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
|
||||||
|
|
||||||
_, err := instance.findUnlockerToRemove("pgp-" + listTestGPGKeyID + "A")
|
_, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
||||||
|
|
||||||
require.ErrorIs(t, err, errStatFailed)
|
require.ErrorIs(t, err, errStatFailed)
|
||||||
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
||||||
|
|||||||
@@ -156,20 +156,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
return k.Directory
|
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 {
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
|
return filepath.Base(k.Directory)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the keychain unlocker
|
// Remove implements Unlocker interface - removes the keychain unlocker
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -60,9 +61,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
return k.Directory
|
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 {
|
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
|
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||||
|
|||||||
@@ -109,12 +109,9 @@ func (p *PassphraseUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
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 {
|
func (p *PassphraseUnlocker) GetID() string {
|
||||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
return filepath.Base(p.Directory)
|
||||||
createdAt := p.Metadata.CreatedAt
|
|
||||||
|
|
||||||
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||||
|
|||||||
@@ -297,11 +297,6 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
// Create a PGP unlocker for the remaining tests
|
// Create a PGP unlocker for the remaining tests
|
||||||
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
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
|
// Test getting identity from PGP unlocker
|
||||||
t.Run("GetIdentity", func(t *testing.T) {
|
t.Run("GetIdentity", func(t *testing.T) {
|
||||||
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
||||||
@@ -396,10 +391,10 @@ func testCreatePGPUnlocker(
|
|||||||
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the key ID includes the GPG fingerprint
|
// Check that the ID is the name of the unlocker's directory
|
||||||
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
|
||||||
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'",
|
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
|
||||||
pgpUnlocker.GetID(), fingerprint)
|
pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory()))
|
||||||
}
|
}
|
||||||
|
|
||||||
checkPGPUnlockerFiles(t, fs, 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
|
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
|
||||||
// keyID into unlockerDir and checks that unlocker decrypts it.
|
// keyID into unlockerDir and checks that unlocker decrypts it.
|
||||||
func testPGPUnlockerGetIdentity(
|
func testPGPUnlockerGetIdentity(
|
||||||
|
|||||||
@@ -155,21 +155,9 @@ func (p *PGPUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
return p.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from GPG key ID.
|
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||||
// 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.
|
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
// Generate ID using GPG key ID: pgp-<keyid>
|
return filepath.Base(p.Directory)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||||
@@ -184,30 +172,6 @@ func (p *PGPUnlocker) Remove() error {
|
|||||||
return nil
|
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
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||||
// based on hostname and time
|
// based on hostname and time
|
||||||
func generatePGPUnlockerName() (string, error) {
|
func generatePGPUnlockerName() (string, error) {
|
||||||
|
|||||||
@@ -130,17 +130,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
return s.Directory
|
return s.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface.
|
// GetID implements Unlocker interface: the name of the unlocker's directory.
|
||||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
hostname, err := os.Hostname()
|
return filepath.Base(s.Directory)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface.
|
// Remove implements Unlocker interface.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -67,9 +68,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
return s.Directory
|
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 {
|
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.
|
// Remove returns an error on non-Darwin platforms.
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
// Test GetDirectory returns the directory we passed in
|
// Test GetDirectory returns the directory we passed in
|
||||||
assert.Equal(t, dir, unlocker.GetDirectory())
|
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||||
|
|
||||||
// Test GetID returns a formatted string with the creation timestamp
|
// Test GetID returns the name of the unlocker's directory
|
||||||
expectedID := "2026-01-15.10.30-secure-enclave"
|
assert.Equal(t, "test-se-unlocker", unlocker.GetID())
|
||||||
assert.Equal(t, expectedID, unlocker.GetID())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||||
|
|||||||
@@ -61,11 +61,9 @@ func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
id := unlocker.GetID()
|
|
||||||
|
|
||||||
// ID should contain the timestamp and "secure-enclave" type
|
// The ID is the name of the unlocker's directory
|
||||||
assert.Contains(t, id, "2026-03-10.14.30")
|
assert.Equal(t, "test", unlocker.GetID())
|
||||||
assert.Contains(t, id, seUnlockerType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateSEKeyLabel(t *testing.T) {
|
func TestGenerateSEKeyLabel(t *testing.T) {
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ type Unlocker interface {
|
|||||||
GetType() string
|
GetType() string
|
||||||
GetMetadata() UnlockerMetadata
|
GetMetadata() UnlockerMetadata
|
||||||
GetDirectory() string
|
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
|
Remove() error // Remove the unlocker and any associated resources
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,8 +188,9 @@ func (v *Vault) findUnlockerByID(
|
|||||||
return nil, skippedDirPath, nil
|
return nil, skippedDirPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnlockers returns a list of available unlockers for this vault
|
// ListUnlockers returns the metadata of each unlocker of this vault, keyed
|
||||||
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
// 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()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -204,7 +205,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []UnlockerMetadata{}, nil
|
return map[string]UnlockerMetadata{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List directories in unlockers.d
|
// 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)
|
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var unlockers []UnlockerMetadata
|
unlockers := map[string]UnlockerMetadata{}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() {
|
if !file.IsDir() {
|
||||||
@@ -222,7 +223,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
|
|
||||||
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
||||||
if ok {
|
if ok {
|
||||||
unlockers = append(unlockers, metadata)
|
unlockers[file.Name()] = metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,8 +454,7 @@ func writePassphraseUnlocker(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the new unlocker by its directory, not by its ID: an old
|
// Make the new unlocker the current one
|
||||||
// passphrase unlocker created in the same minute has the same ID.
|
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, currentUnlockerPath,
|
err = secret.WriteFileAtomic(fs, currentUnlockerPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user