Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions pkg/secrets/backend_registry_review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ func TestOpenKeyFromStoreClassifiesMissingAutoAndKeyringKeysAsUnavailable(t *tes
func TestProbeFileRespectsPromptPolicyForLegacyOwnership(t *testing.T) {
const passphrase = "correct horse battery staple"
dir := t.TempDir()
key, err := passphraseFileKey(passphrase)
require.NoError(t, err)
key := testPassphraseFileKey(t, passphrase)
path := filepath.Join(dir, EncryptedFileName)
require.NoError(
t,
Expand Down
21 changes: 19 additions & 2 deletions pkg/secrets/legacy_file_recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"

"filippo.io/age"
"github.com/stretchr/testify/require"
)

Expand All @@ -26,11 +27,27 @@ func (r unavailableLegacyKeyring) Probe(kind Backend, idx *index, key string) (b
return r.systemBackendRegistry.Probe(kind, idx, key)
}

// Keep production recovery and credential checks, reducing only subsequent writes.
func (r unavailableLegacyKeyring) Open(
kind Backend,
idx *index,
intent BackendOpenIntent,
) (backend, error) {
b, err := r.systemBackendRegistry.Open(kind, idx, intent)
if err == nil {
if file, ok := b.(*fileBackend); ok {
if recipient, ok := file.key.recipient.(*age.ScryptRecipient); ok {
recipient.SetWorkFactor(testScryptWorkFactor)
}
}
}
return b, err
}

func legacyRecoveryFixture(t *testing.T, credential string) (*localStore, *fileBackend) {
t.Helper()
dir := t.TempDir()
key, err := passphraseFileKey(legacyRecoveryPassphrase)
require.NoError(t, err)
key := testPassphraseFileKey(t, legacyRecoveryPassphrase)
blob := newFileBackend(filepath.Join(dir, EncryptedFileName), key)
require.NoError(
t,
Expand Down
76 changes: 76 additions & 0 deletions pkg/secrets/passphrase_test_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package secrets

import (
"bytes"
"os"
"path/filepath"
"testing"

"filippo.io/age"
"github.com/stretchr/testify/require"
)

// Functional fixtures use real age/scrypt at a cost unsuitable for user secrets.
// TestProductionPassphraseEncryptionCompatibility verifies production defaults.
const testScryptWorkFactor = 10

func testPassphraseFileKey(t *testing.T, passphrase string) *fileKey {
t.Helper()
recipient, err := age.NewScryptRecipient(passphrase)
require.NoError(t, err)
recipient.SetWorkFactor(testScryptWorkFactor)
identity, err := age.NewScryptIdentity(passphrase)
require.NoError(t, err)
return &fileKey{recipient: recipient, identity: identity, source: keySourcePassphrase}
}

func useFastPassphraseTargets(t *testing.T, p *ProtectionManager) {
t.Helper()
p.resolveNewFileKey = func(dir, passphrase string) (*fileKey, error) {
if passphrase == "" {
return resolveFileKey(dir, passphrase)
}
return testPassphraseFileKey(t, passphrase), nil
}
}

func TestPassphraseFixtureWorkFactor(t *testing.T) {
t.Setenv(EnvPassphrase, "fixture passphrase")
path := filepath.Join(t.TempDir(), EncryptedFileName)
backend := newPassphraseBackend(t, path, "fixture passphrase")
require.NoError(t, backend.set("default/FIXTURE", "fixture value"))
blob, err := os.ReadFile(path) // #nosec G304 -- isolated test directory.
require.NoError(t, err)
assertScryptHeader(t, blob, "10")

key, err := openPassphraseFileKey()
require.NoError(t, err)
value, err := newFileBackend(path, key).get("default/FIXTURE")
require.NoError(t, err)
require.Equal(t, "fixture value", value)
}

type scryptHeaderInspector struct {
stanzas []*age.Stanza
}

func (i *scryptHeaderInspector) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
i.stanzas = stanzas
return nil, age.ErrIncorrectIdentity
}

func assertScryptHeader(t *testing.T, blob []byte, workFactor string) {
t.Helper()
header, err := age.ExtractHeader(bytes.NewReader(blob))
require.NoError(t, err)
// Public Identity callbacks expose parsed stanzas without deriving a key.
inspector := &scryptHeaderInspector{}
_, err = age.DecryptHeader(header, inspector)
var noMatch *age.NoIdentityMatchError
require.ErrorAs(t, err, &noMatch)
require.Len(t, inspector.stanzas, 1)
stanza := inspector.stanzas[0]
require.Equal(t, "scrypt", stanza.Type)
require.Len(t, stanza.Args, 2)
require.Equal(t, workFactor, stanza.Args[1])
}
2 changes: 2 additions & 0 deletions pkg/secrets/protection.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ type ProtectionManager struct {
readRemembered func() (string, error)
saveRemembered func(string) error
forgetRemembered func() error
// resolveNewFileKey overrides only the rekey target for individual test managers.
resolveNewFileKey func(dir, passphrase string) (*fileKey, error)
// phase is used for deterministic crash injection in recovery tests.
phase func(string) error
}
Expand Down
9 changes: 8 additions & 1 deletion pkg/secrets/protection_rekey.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func (p *ProtectionManager) rekey(passphrase string, requirePassphrase, automati
if err != nil {
return err
}
target, err := resolveFileKey(p.dir, passphrase)
target, err := p.newTargetFileKey(passphrase)
if err != nil {
return err
}
Expand Down Expand Up @@ -170,3 +170,10 @@ func requireEncryptedFile(dir string) error {
}
return err
}

func (p *ProtectionManager) newTargetFileKey(passphrase string) (*fileKey, error) {
if p.resolveNewFileKey != nil {
return p.resolveNewFileKey(p.dir, passphrase)
}
return resolveFileKey(p.dir, passphrase)
}
112 changes: 106 additions & 6 deletions pkg/secrets/rekey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand All @@ -22,6 +23,7 @@ const (
func testProtectionManager(t *testing.T, dir, passphrase string) *ProtectionManager {
t.Helper()
p := NewProtectionManager(dir, DefaultUnlockResolver{ExplicitPassphrase: passphrase})
useFastPassphraseTargets(t, p)
p.readRemembered = func() (string, error) { return "", nil }
p.forgetRemembered = func() error { return nil }
return p
Expand Down Expand Up @@ -372,8 +374,7 @@ func TestRekeyAutomaticTransitionAndReset(t *testing.T) {
func TestRememberVerifiesCredentialAndForgetDoesNotChangeBlob(t *testing.T) {
dir := t.TempDir()
credential := "remember-test-passphrase"
key, err := passphraseFileKey(credential)
require.NoError(t, err)
key := testPassphraseFileKey(t, credential)
path := filepath.Join(dir, EncryptedFileName)
require.NoError(
t,
Expand Down Expand Up @@ -520,8 +521,7 @@ func TestProtectionStatusNeverPromptsAndRememberRequiresCiphertext(t *testing.T)
require.NoError(t, err)
require.Equal(t, SecretMissing, status.Availability)
require.ErrorIs(t, p.ChangePassphrase("replacement-for-missing-blob"), ErrSecretNotFound)
key, err := passphraseFileKey("actual-passphrase")
require.NoError(t, err)
key := testPassphraseFileKey(t, "actual-passphrase")
require.NoError(
t,
newFileBackend(filepath.Join(dir, EncryptedFileName), key).store(map[string]string{}),
Expand Down Expand Up @@ -595,8 +595,7 @@ func TestInvalidRecoveryJournalDoesNotChangeStore(t *testing.T) {
func testPassphraseProtectionFixture(t *testing.T, passphrase string) *ProtectionManager {
t.Helper()
dir := t.TempDir()
key, err := passphraseFileKey(passphrase)
require.NoError(t, err)
key := testPassphraseFileKey(t, passphrase)
values := map[string]string{testRekeyBackendKey: testRekeyProtectedValue}
require.NoError(t, newFileBackend(filepath.Join(dir, EncryptedFileName), key).store(values))
idx, err := loadIndex(filepath.Join(dir, IndexFileName))
Expand All @@ -613,3 +612,104 @@ func testPassphraseProtectionFixture(t *testing.T, passphrase string) *Protectio
require.NoError(t, idx.save())
return testProtectionManager(t, dir, passphrase)
}

func TestProductionPassphraseEncryptionCompatibility(t *testing.T) {
const (
passphrase = "production compatibility test passphrase"
value = "exact recovered production fixture value"
)
key := backendKey("default", "LEGACY")
dir := t.TempDir()
resolver := DefaultUnlockResolver{ExplicitPassphrase: passphrase}
manager := NewProtectionManager(dir, resolver)
require.NoError(t, manager.SetPassphrase(passphrase))

indexPath := filepath.Join(dir, IndexFileName)
idx, err := loadIndex(indexPath)
require.NoError(t, err)
require.Equal(t, string(keySourcePassphrase), idx.data.KeySource)
require.Equal(t, string(keySourcePassphrase), idx.data.FileStore.KeySource)
fk, err := openExistingFileKeyWithResolver(dir, idx, resolver)
require.NoError(t, err)
blobPath := filepath.Join(dir, EncryptedFileName)
initialBlob, err := os.ReadFile(blobPath) // #nosec G304 -- isolated test directory.
require.NoError(t, err)
assertScryptHeader(t, initialBlob, "18")
backend := newFileBackend(blobPath, fk)
values, err := backend.load()
require.NoError(t, err)
require.Empty(t, values)
require.NoError(t, backend.set(key, value))

freshKey, err := openExistingFileKeyWithResolver(dir, idx, resolver)
require.NoError(t, err)
values, err = newFileBackend(blobPath, freshKey).load()
require.NoError(t, err)
require.Equal(t, map[string]string{key: value}, values)
blob, err := os.ReadFile(blobPath) // #nosec G304 -- isolated test directory.
require.NoError(t, err)
assertScryptHeader(t, blob, "18")
assertProductionFilePermissions(t, blobPath)

wrongKey, err := openExistingFileKeyWithResolver(dir, idx,
DefaultUnlockResolver{ExplicitPassphrase: "incorrect compatibility passphrase"})
require.NoError(t, err)
values, err = newFileBackend(blobPath, wrongKey).load()
require.ErrorIs(t, err, ErrUnlockFailed)
require.Nil(t, values)
unchanged, err := os.ReadFile(blobPath) // #nosec G304 -- isolated test directory.
require.NoError(t, err)
require.Equal(t, blob, unchanged)
assertProductionLegacyCompatibility(t, resolver, blob, map[string]string{key: value})
}

func assertProductionFilePermissions(t *testing.T, path string) {
t.Helper()
const windowsOS = "windows"
if runtime.GOOS == windowsOS {
return
}
info, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}

func assertProductionLegacyCompatibility(
t *testing.T, resolver UnlockMaterialResolver, blob []byte, expected map[string]string,
) {
t.Helper()

// Reuse the production ciphertext with the catalog layout of older CLIs.
legacyDir := t.TempDir()
legacyBlobPath := filepath.Join(legacyDir, EncryptedFileName)
legacyIndexPath := filepath.Join(legacyDir, IndexFileName)
legacyCatalog := []byte(
"contexts:\n default:\n LEGACY:\n name: LEGACY\n context: default\n kind: secret\n",
)
require.NoError(
t,
os.WriteFile(legacyBlobPath, blob, 0o600),
) // #nosec G703 -- isolated test directory.
require.NoError(t, os.WriteFile(legacyIndexPath, legacyCatalog, 0o600))
legacyIdx, err := loadIndex(legacyIndexPath)
require.NoError(t, err)
require.Empty(t, legacyIdx.data.KeySource)
registry, ok := newSystemBackendRegistry(legacyDir, resolver).(*systemBackendRegistry)
require.True(t, ok)
present, conclusive := registry.probeFile(legacyIdx, backendKey("default", "LEGACY"))
require.True(t, present)
require.True(t, conclusive)
legacyKey, err := openPassphraseFileKeyWithResolver(resolver)
require.NoError(t, err)
values, err := newFileBackend(legacyBlobPath, legacyKey).load()
require.NoError(t, err)
require.Equal(t, expected, values)
legacyBlobAfter, err := os.ReadFile(legacyBlobPath) // #nosec G304 -- isolated test directory.
require.NoError(t, err)
require.Equal(t, blob, legacyBlobAfter)
legacyCatalogAfter, err := os.ReadFile(
legacyIndexPath,
) // #nosec G304 -- isolated test directory.
require.NoError(t, err)
require.Equal(t, legacyCatalog, legacyCatalogAfter)
}
8 changes: 2 additions & 6 deletions pkg/secrets/store_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,10 +399,7 @@ func TestStore_InvalidNameRejected(t *testing.T) {

func newPassphraseBackend(t *testing.T, path, passphrase string) *fileBackend {
t.Helper()
key, err := resolveFileKey(t.TempDir(), passphrase)
if err != nil {
t.Fatal(err)
}
key := testPassphraseFileKey(t, passphrase)
return newFileBackend(path, key)
}

Expand Down Expand Up @@ -940,8 +937,7 @@ func TestProbeFileReadsLegacyPassphraseBackend(t *testing.T) {
t.Setenv(EnvPassphrase, "correct horse battery staple")
dir := t.TempDir()

fk, err := openPassphraseFileKey()
require.NoError(t, err)
fk := testPassphraseFileKey(t, "correct horse battery staple")
require.NoError(t, newFileBackend(filepath.Join(dir, EncryptedFileName), fk).
set(backendKey(testContext, "LEGACY"), "recovered"))
indexPath := filepath.Join(dir, IndexFileName)
Expand Down
3 changes: 1 addition & 2 deletions pkg/secrets/unlock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,7 @@ func TestInspectDecryptsFileOnlyOnce(t *testing.T) {

func TestWrongHigherPriorityCredentialNeverFallsBack(t *testing.T) {
dir := t.TempDir()
key, err := passphraseFileKey("correct phrase")
require.NoError(t, err)
key := testPassphraseFileKey(t, "correct phrase")
require.NoError(
t,
newFileBackend(
Expand Down
Loading