Compare commits
1
Commits
next
...
25cc2cbe52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25cc2cbe52 |
@@ -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,26 +60,17 @@ 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++
|
|
||||||
if f.opens > f.openBudget {
|
|
||||||
return nil, errUnlockersDirUnreadable
|
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
|
||||||
return f.Fs.Open(name)
|
return f.Fs.Open(name)
|
||||||
@@ -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