Compare commits
1
Commits
next
...
35d73dfdb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35d73dfdb2 |
@@ -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,18 @@ 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. The keychain and Secure Enclave code was type-checked by
|
||||||
|
`script/lint-darwin`, never run; a test on Linux lists, selects and removes
|
||||||
|
each of two passphrase unlockers created in one minute by its own ID.
|
||||||
- 2026-10-04: A failed `secret unlocker add keychain` or
|
- 2026-10-04: A failed `secret unlocker add keychain` or
|
||||||
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
||||||
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
||||||
|
|||||||
@@ -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 " +
|
||||||
|
|||||||
+10
-12
@@ -697,9 +697,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",
|
||||||
@@ -889,16 +887,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 +935,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,74 @@
|
|||||||
|
//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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSameMinuteUnlockersHaveTheirOwnIDs writes two passphrase unlockers
|
||||||
|
// created in the same minute side by side, as an `unlocker add passphrase`
|
||||||
|
// that fails before removing the old one leaves them, and asserts that
|
||||||
|
// `unlocker list` gives 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 TestSameMinuteUnlockersHaveTheirOwnIDs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
|
testMnemonicBuffer(t))
|
||||||
|
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.30.000000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, dirName := range dirNames {
|
||||||
|
metadata, err := json.Marshal(secret.UnlockerMetadata{
|
||||||
|
Type: unlockerTypePassphrase,
|
||||||
|
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 30*i, 0, time.UTC),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
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 newer one first: an ID both shared would remove the older 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)
|
||||||
|
}
|
||||||
@@ -48,13 +48,12 @@ 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"
|
||||||
|
|
||||||
@@ -142,8 +141,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,
|
||||||
@@ -256,7 +255,7 @@ func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
|
|||||||
|
|
||||||
require.Len(t, unlockers, 1,
|
require.Len(t, unlockers, 1,
|
||||||
"only the entry whose directory was readable may be listed")
|
"only the entry whose directory was readable may be listed")
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
|
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID,
|
||||||
"the surviving row must carry the real unlocker ID")
|
"the surviving row must carry the real unlocker ID")
|
||||||
assert.True(t, unlockers[0].IsCurrent,
|
assert.True(t, unlockers[0].IsCurrent,
|
||||||
"the current-unlocker marker must survive the skip")
|
"the current-unlocker marker must survive the skip")
|
||||||
@@ -272,20 +271,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 is
|
||||||
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
// still listed, under its 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 +300,12 @@ 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},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +371,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
|
||||||
|
|||||||
@@ -396,10 +396,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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,8 +430,7 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
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(v.fs, currentUnlockerPath,
|
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user