diff --git a/pkg/secrets/backend_registry_review_test.go b/pkg/secrets/backend_registry_review_test.go index 4f640f05d8..fc3cbc7050 100644 --- a/pkg/secrets/backend_registry_review_test.go +++ b/pkg/secrets/backend_registry_review_test.go @@ -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, diff --git a/pkg/secrets/legacy_file_recovery_test.go b/pkg/secrets/legacy_file_recovery_test.go index d00db91f0c..bf616731cb 100644 --- a/pkg/secrets/legacy_file_recovery_test.go +++ b/pkg/secrets/legacy_file_recovery_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "filippo.io/age" "github.com/stretchr/testify/require" ) @@ -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, diff --git a/pkg/secrets/passphrase_test_helpers_test.go b/pkg/secrets/passphrase_test_helpers_test.go new file mode 100644 index 0000000000..2e3e1f6517 --- /dev/null +++ b/pkg/secrets/passphrase_test_helpers_test.go @@ -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]) +} diff --git a/pkg/secrets/protection.go b/pkg/secrets/protection.go index 6ed5af79ef..405de5ba4f 100644 --- a/pkg/secrets/protection.go +++ b/pkg/secrets/protection.go @@ -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 } diff --git a/pkg/secrets/protection_rekey.go b/pkg/secrets/protection_rekey.go index 8c5b773c26..786254ccd8 100644 --- a/pkg/secrets/protection_rekey.go +++ b/pkg/secrets/protection_rekey.go @@ -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 } @@ -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) +} diff --git a/pkg/secrets/rekey_test.go b/pkg/secrets/rekey_test.go index 35e5e38421..44f570aac4 100644 --- a/pkg/secrets/rekey_test.go +++ b/pkg/secrets/rekey_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "testing" @@ -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 @@ -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, @@ -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{}), @@ -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)) @@ -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) +} diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index 07f03ffe16..62fd860ce7 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -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) } @@ -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) diff --git a/pkg/secrets/unlock_test.go b/pkg/secrets/unlock_test.go index 23dfe982b8..15ceb26a86 100644 --- a/pkg/secrets/unlock_test.go +++ b/pkg/secrets/unlock_test.go @@ -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(