Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952627fd51 |
@@ -25,6 +25,12 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-03: A PGP unlocker whose metadata has no usable GPG key ID
|
||||||
|
no longer panics: `GetID()` warns with the unlocker's directory and
|
||||||
|
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
|
||||||
|
unlocker whose metadata file cannot be read or parsed instead of
|
||||||
|
failing, so `secret unlocker list` still lists the others; the
|
||||||
|
listing's ID lookup no longer warns about that directory again.
|
||||||
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
|
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
|
||||||
the exit code after its deferred `memguard.Purge()` has run, and only
|
the exit code after its deferred `memguard.Purge()` has run, and only
|
||||||
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
|
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
|
||||||
@@ -118,8 +124,6 @@ Bring the repo into policy compliance in one commit:
|
|||||||
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
||||||
209-216); non-constant-time public key compare (vault.go:95-100).
|
209-216); non-constant-time public key compare (vault.go:95-100).
|
||||||
- High priority:
|
- High priority:
|
||||||
- Return errors instead of panicking on corrupted metadata
|
|
||||||
(pgpunlocker.go:116, keychainunlocker.go:141).
|
|
||||||
- Secure temporary file handling and cleanup.
|
- Secure temporary file handling and cleanup.
|
||||||
- Print cobra usage only for argument errors, not internal
|
- Print cobra usage only for argument errors, not internal
|
||||||
failures.
|
failures.
|
||||||
|
|||||||
@@ -349,6 +349,10 @@ func unlockerIDFromDir(
|
|||||||
// itself cannot be read. Callers must distinguish the two: an unreadable
|
// itself cannot be read. Callers must distinguish the two: an unreadable
|
||||||
// directory means the unlocker's real ID is unknowable, so the entry has
|
// directory means the unlocker's real ID is unknowable, so the entry has
|
||||||
// to be skipped rather than reported under a synthesized ID.
|
// 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(
|
func findUnlockerIDByMetadata(
|
||||||
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
||||||
includeSecureEnclave bool,
|
includeSecureEnclave bool,
|
||||||
@@ -371,9 +375,6 @@ func findUnlockerIDByMetadata(
|
|||||||
// Check if this is the right unlocker by comparing metadata
|
// Check if this is the right unlocker by comparing metadata
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlocker metadata file",
|
|
||||||
"path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,9 +382,6 @@ func findUnlockerIDByMetadata(
|
|||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not parse unlocker metadata file",
|
|
||||||
"path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
// Unlocker List Tests
|
// Unlocker List Tests
|
||||||
//
|
//
|
||||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory
|
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
||||||
// cannot be read while the listing is being rendered:
|
// or an unlocker's metadata in it, cannot be read while the listing is
|
||||||
|
// being rendered:
|
||||||
//
|
//
|
||||||
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
||||||
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
||||||
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
||||||
// still listed, with its real ID and its current-unlocker marker,
|
// still listed, with its real ID and its current-unlocker marker,
|
||||||
// when a later entry's scan fails.
|
// when a later entry's scan fails.
|
||||||
|
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||||
|
// metadata does not stop the others from being listed.
|
||||||
|
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||||
|
// file cannot be read is left out, and the other is still listed.
|
||||||
//
|
//
|
||||||
// The listing resolves each unlocker's real ID by rescanning unlockers.d
|
// 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
|
// after the vault has already enumerated it. If that rescan fails the ID
|
||||||
@@ -92,6 +97,28 @@ func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
|||||||
return f.Fs.Open(name)
|
return f.Fs.Open(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errMetadataUnreadable is returned by the test filesystem in place of a
|
||||||
|
// successful open of one unlocker's metadata file.
|
||||||
|
var errMetadataUnreadable = errors.New("input/output error")
|
||||||
|
|
||||||
|
// metadataReadFailFs fails every open of the file at unreadablePath. The
|
||||||
|
// file still exists, so checking for it succeeds and only reading it fails.
|
||||||
|
type metadataReadFailFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
unreadablePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
|
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
|
||||||
|
if name == f.unreadablePath {
|
||||||
|
return nil, errMetadataUnreadable
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||||
|
return f.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
||||||
// yields the real ID "pgp-<keyID>".
|
// yields the real ID "pgp-<keyID>".
|
||||||
func writePGPUnlocker(
|
func writePGPUnlocker(
|
||||||
@@ -227,3 +254,79 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
|||||||
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
|
||||||
|
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
||||||
|
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
|
||||||
|
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
||||||
|
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
healthyID := "pgp-" + listTestGPGKeyID + "A"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
metadata string
|
||||||
|
wantIDs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not JSON",
|
||||||
|
metadata: "not json",
|
||||||
|
wantIDs: []string{healthyID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GPG key ID of the wrong type",
|
||||||
|
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
||||||
|
wantIDs: []string{healthyID, "pgp-unknown"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GPG key ID missing",
|
||||||
|
metadata: `{"type": "pgp"}`,
|
||||||
|
wantIDs: []string{healthyID, "pgp-unknown"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newListTestVault(t, 2)
|
||||||
|
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
|
||||||
|
listTestVaultName, listTestUnlockersDirName,
|
||||||
|
listTestUnlockerDirTwo, listTestMetadataFileName)
|
||||||
|
require.NoError(t, afero.WriteFile(
|
||||||
|
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
|
||||||
|
))
|
||||||
|
|
||||||
|
unlockers := listUnlockersJSON(t, fs)
|
||||||
|
require.Len(t, unlockers, len(tt.wantIDs))
|
||||||
|
|
||||||
|
for i, wantID := range tt.wantIDs {
|
||||||
|
assert.Equal(t, wantID, unlockers[i].ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose
|
||||||
|
// metadata file exists but cannot be read is left out of the listing, and
|
||||||
|
// the other unlocker is still listed with its real ID. The unreadable one
|
||||||
|
// sorts first, so finding the other's ID has to step past it as well.
|
||||||
|
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 2)
|
||||||
|
fs := &metadataReadFailFs{
|
||||||
|
Fs: base,
|
||||||
|
unreadablePath: filepath.Join(listTestStateDir, "vaults.d",
|
||||||
|
listTestVaultName, listTestUnlockersDirName,
|
||||||
|
listTestUnlockerDirOne, listTestMetadataFileName),
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockers := listUnlockersJSON(t, fs)
|
||||||
|
|
||||||
|
require.Len(t, unlockers, 1,
|
||||||
|
"only the unlocker with readable metadata may be listed")
|
||||||
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
|
||||||
|
"the listed row must carry the real unlocker ID")
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,14 +155,18 @@ 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 - generates ID from GPG key ID.
|
||||||
|
// If the metadata has no usable GPG key ID, it warns with the unlocker's
|
||||||
|
// directory and returns "pgp-unknown", so listing the other unlockers
|
||||||
|
// still works.
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
// Generate ID using GPG key ID: pgp-<keyid>
|
// Generate ID using GPG key ID: pgp-<keyid>
|
||||||
gpgKeyID, err := p.GetGPGKeyID()
|
gpgKeyID, err := p.GetGPGKeyID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The vault metadata is corrupt - this is a fatal error
|
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
|
||||||
// We cannot continue with a fallback ID as that would mask data corruption
|
"directory", p.Directory, "error", err)
|
||||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
|
||||||
|
return "pgp-unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
return "pgp-" + gpgKeyID
|
return "pgp-" + gpgKeyID
|
||||||
@@ -197,6 +201,10 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
|||||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pgpMetadata.GPGKeyID == "" {
|
||||||
|
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
return pgpMetadata.GPGKeyID, nil
|
return pgpMetadata.GPGKeyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,16 +247,20 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
secret.Warn("Skipping unlocker directory with unreadable metadata file",
|
||||||
"failed to read metadata for unlocker %s: %w", file.Name(), err)
|
"directory", file.Name(), "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
var metadata UnlockerMetadata
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
secret.Warn("Skipping unlocker directory with corrupt metadata file",
|
||||||
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
"directory", file.Name(), "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockers = append(unlockers, metadata)
|
unlockers = append(unlockers, metadata)
|
||||||
|
|||||||
Reference in New Issue
Block a user