From 402e569cb709d2ecc677152140f3020e69dff988 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 00:52:58 -0600 Subject: [PATCH 01/18] fix(secrets): recover legacy backend ownership Signed-off-by: Samuel K --- pkg/secrets/backend_registry.go | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/pkg/secrets/backend_registry.go b/pkg/secrets/backend_registry.go index cb936a181..6b34594f9 100644 --- a/pkg/secrets/backend_registry.go +++ b/pkg/secrets/backend_registry.go @@ -1,6 +1,7 @@ package secrets import ( + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,10 @@ import ( type backendRegistry interface { Open(kind Backend, idx *index, create bool) (backend, error) ResolveForNewSecret(preference Backend, idx *index) (Backend, error) + // Probe reports whether kind's backend holds key. conclusive is false when + // the backend cannot answer (unavailable or unreadable), so callers never + // treat an unprobeable backend as empty. + Probe(kind Backend, idx *index, key string) (present, conclusive bool) } type fixedBackendRegistry struct{ b backend } @@ -20,6 +25,12 @@ func (r fixedBackendRegistry) Open(_ Backend, _ *index, _ bool) (backend, error) func (r fixedBackendRegistry) ResolveForNewSecret(_ Backend, _ *index) (Backend, error) { return BackendKeyring, nil } +func (r fixedBackendRegistry) Probe(kind Backend, _ *index, key string) (bool, bool) { + if kind != BackendKeyring { + return false, false + } + return probePresence(r.b, key) +} type systemBackendRegistry struct{ dir string } @@ -53,6 +64,46 @@ func (r *systemBackendRegistry) Open(kind Backend, idx *index, create bool) (bac } } +func (r *systemBackendRegistry) Probe(kind Backend, idx *index, key string) (bool, bool) { + switch kind { + case BackendKeyring: + if !keyringAvailable() { + return false, false + } + return probePresence(keyringBackend{}, key) + case BackendFile: + path := filepath.Join(r.dir, EncryptedFileName) + if _, err := os.Stat(path); err != nil { + // No encrypted file means the file backend provably holds nothing. + return false, true + } + fk, err := openExistingFileKey(r.dir, idx) + if err != nil && idx.data.KeySource == "" { + // Legacy indexes may predate persisted key-source metadata; a + // configured passphrase still proves ownership read-only. + fk, err = openPassphraseFileKey() + } + if err != nil { + return false, false + } + return probePresence(newFileBackend(path, fk), key) + default: + return false, false + } +} + +func probePresence(b backend, key string) (present, conclusive bool) { + _, err := b.get(key) + switch { + case err == nil: + return true, true + case errors.Is(err, ErrSecretNotFound): + return false, true + default: + return false, false + } +} + func (r *systemBackendRegistry) ResolveForNewSecret( preference Backend, idx *index, From 4c546d70236ac2a1056f387bfe0a939ddbc2f831 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 00:53:29 -0600 Subject: [PATCH 02/18] fix(secrets): tolerate legacy ownership metadata Signed-off-by: Samuel K --- pkg/secrets/index.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go index 6b9ec2646..b68748c10 100644 --- a/pkg/secrets/index.go +++ b/pkg/secrets/index.go @@ -61,6 +61,10 @@ func (i *index) normalizeKinds() { } } +// validateBackends rejects structurally invalid entries. A sensitive entry +// without a recorded backend is not invalid: indexes written before backend +// ownership was persisted are repaired by the store, which proves ownership +// from the backends themselves. func (i *index) validateBackends() error { for context, entries := range i.data.Contexts { for name, meta := range entries { @@ -84,14 +88,7 @@ func validateBackend(context, name string, meta SecretMeta) error { } return nil } - if meta.Backend == "" { - return fmt.Errorf( - "secret %s/%s is missing persisted backend ownership", - context, - name, - ) - } - if meta.Backend != BackendKeyring && meta.Backend != BackendFile { + if meta.Backend != "" && meta.Backend != BackendKeyring && meta.Backend != BackendFile { return fmt.Errorf( "invalid secrets backend %q for %s/%s", meta.Backend, From 2bc6d4a0317a07e3724d043b0d8d137d3561b54b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 00:53:45 -0600 Subject: [PATCH 03/18] fix(secrets): repair legacy backend ownership Signed-off-by: Samuel K --- pkg/secrets/local_store.go | 140 +++++++++++++++++++++++++++++++------ 1 file changed, 117 insertions(+), 23 deletions(-) diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index 9ff05ddab..3b9b2ec9c 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -101,15 +101,13 @@ func (s *localStore) Set(context, name, value string, kind Kind) error { if err != nil { return err } + s.repairLegacyOwnership(idx) meta, exists := idx.get(context, name) if !exists { meta = SecretMeta{Name: name, Context: context, Created: s.now().UTC()} } wasSensitive := exists && meta.Sensitive() - if wasSensitive && meta.Backend == "" { - return fmt.Errorf("secret %q is missing persisted backend ownership", name) - } meta.Kind = kind meta.Value = value @@ -134,7 +132,7 @@ func (s *localStore) Get(context, name string) (string, error) { return "", err } - idx, err := loadIndex(s.indexPath) + idx, err := s.loadRepaired() if err != nil { return "", err } @@ -150,7 +148,7 @@ func (s *localStore) Get(context, name string) (string, error) { return "", err } if meta.Backend == "" { - return "", fmt.Errorf("secret %q is missing persisted backend ownership", name) + return "", unownedSecretError(context, name) } b, err := s.backends.Open(meta.Backend, idx, false) if err != nil { @@ -183,12 +181,12 @@ func (s *localStore) Delete(context, name string) error { if err != nil { return err } + s.repairLegacyOwnership(idx) if meta, ok := idx.get(context, name); ok && meta.Sensitive() { if meta.Backend == "" { - return fmt.Errorf("secret %q is missing persisted backend ownership", name) - } - if meta.Backend != "" { + s.removeFromProbeableBackends(idx, backendKey(context, name)) + } else { b, openErr := s.backends.Open(meta.Backend, idx, false) if openErr != nil { return openErr @@ -204,7 +202,7 @@ func (s *localStore) Delete(context, name string) error { } func (s *localStore) Meta(context, name string) (SecretMeta, error) { - idx, err := loadIndex(s.indexPath) + idx, err := s.loadRepaired() if err != nil { return SecretMeta{}, err } @@ -220,22 +218,20 @@ func (s *localStore) Meta(context, name string) (SecretMeta, error) { // List returns the context's entries, flagging sensitive entries whose owned // backend value is missing. func (s *localStore) List(context string) ([]SecretMeta, error) { - idx, err := loadIndex(s.indexPath) + idx, err := s.loadRepaired() if err != nil { return nil, err } entries := idx.list(context) - if !anySensitive(entries) { - return entries, nil - } for i := range entries { if !entries[i].Sensitive() { continue } meta := entries[i] if meta.Backend == "" { - return nil, fmt.Errorf("secret %q is missing persisted backend ownership", meta.Name) + entries[i].Orphaned = true + continue } b, err := s.backends.Open(meta.Backend, idx, false) if err != nil { @@ -278,15 +274,6 @@ func (s *localStore) touchLastUsed(context, name string) { _ = idx.save() } -func anySensitive(entries []SecretMeta) bool { - for i := range entries { - if entries[i].Sensitive() { - return true - } - } - return false -} - func (s *localStore) persistValue( idx *index, meta *SecretMeta, value string, wasSensitive bool, ) error { @@ -308,6 +295,9 @@ func (s *localStore) persistSensitive( return err } meta.Backend = resolved + // Fresh ownership means no reachable backend state to preserve, so the + // backend may initialize its key material. + create = true } b, err := s.backends.Open(meta.Backend, idx, create) if err != nil { @@ -326,6 +316,10 @@ func (s *localStore) removeSensitive(idx *index, meta *SecretMeta) error { if err := s.checkKeySource(idx); err != nil { return err } + if meta.Backend == "" { + s.removeFromProbeableBackends(idx, backendKey(meta.Context, meta.Name)) + return nil + } b, err := s.backends.Open(meta.Backend, idx, false) if err != nil { return err @@ -344,3 +338,103 @@ func (s *localStore) checkKeySource(idx *index) error { s.keySource, ) } + +// loadRepaired loads the index and repairs legacy entries written before +// backend ownership was persisted, persisting proven repairs best-effort. +func (s *localStore) loadRepaired() (*index, error) { + idx, err := loadIndex(s.indexPath) + if err != nil { + return nil, err + } + if s.repairLegacyOwnership(idx) { + s.persistRepairs() + } + return idx, nil +} + +// persistRepairs rewrites the index under the store lock so ownership proven +// during a lock-free read survives; failures only delay the repair to the +// next operation. +func (s *localStore) persistRepairs() { + unlock, err := s.lock() + if err != nil { + return + } + defer unlock() + + idx, err := loadIndex(s.indexPath) + if err != nil { + return + } + if s.repairLegacyOwnership(idx) { + _ = idx.save() + } +} + +// repairLegacyOwnership assigns a backend to sensitive entries that predate +// persisted ownership. Ownership is assigned only when exactly one backend +// proves it holds the value; an entry whose value is proven in both backends, +// or in none, stays unowned rather than guessing and reading the wrong value +// later. A backend that cannot answer does not block repair: it cannot serve +// reads either, so the proven backend is the only usable source. +func (s *localStore) repairLegacyOwnership(idx *index) bool { + repaired := false + for context, entries := range idx.data.Contexts { + for name, meta := range entries { + if !meta.Sensitive() || meta.Backend != "" { + continue + } + owner, proven := s.provenOwner(idx, backendKey(context, name)) + if !proven { + continue + } + meta.Backend = owner + entries[name] = meta + if owner == BackendFile && idx.data.KeySource == "" { + // The file probe could only succeed without persisted key-source + // metadata through the configured passphrase. + idx.data.KeySource = string(keySourcePassphrase) + } + repaired = true + } + } + return repaired +} + +func (s *localStore) provenOwner(idx *index, key string) (Backend, bool) { + var found []Backend + for _, kind := range []Backend{BackendKeyring, BackendFile} { + present, _ := s.backends.Probe(kind, idx, key) + if present { + found = append(found, kind) + } + } + if len(found) != 1 { + return "", false + } + return found[0], true +} + +// removeFromProbeableBackends drops key from every backend that answers, +// because an unowned entry cannot name the backend holding its value. +func (s *localStore) removeFromProbeableBackends(idx *index, key string) { + for _, kind := range []Backend{BackendKeyring, BackendFile} { + b, err := s.backends.Open(kind, idx, false) + if err != nil { + continue + } + _ = b.remove(key) + } +} + +func unownedSecretError(context, name string) error { + return fmt.Errorf( + "secret %s/%s has no proven owning backend and its value could not be "+ + "located; set it again with `devsy secret set %s` or remove it with "+ + "`devsy secret delete %s`", + context, + name, + name, + name, + ) +} From 16449d2f0d748320df0eff26639e86671193806c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 00:53:56 -0600 Subject: [PATCH 04/18] Update store_internal_test.go Signed-off-by: Samuel K --- pkg/secrets/store_internal_test.go | 254 +++++++++++++++++++++++++---- 1 file changed, 224 insertions(+), 30 deletions(-) diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index 44c833336..e7c1afeea 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -96,6 +96,14 @@ func (r mapBackendRegistry) ResolveForNewSecret(preference Backend, _ *index) (B return preference, nil } +func (r mapBackendRegistry) Probe(kind Backend, _ *index, key string) (bool, bool) { + b, ok := r.backends[kind] + if !ok { + return false, false + } + return probePresence(b, key) +} + func (m *mapBackend) set(key, value string) error { m.values[key] = value return nil @@ -403,52 +411,169 @@ func TestFileBackend_AutoKeyRoundTrip(t *testing.T) { } } -func TestStore_RejectsMissingBackendOwnership(t *testing.T) { +func legacyIndexYAML(entries string) string { + return "contexts:\n default:\n" + entries +} + +func writeLegacyIndex(t *testing.T, entries string) string { + t.Helper() path := filepath.Join(t.TempDir(), IndexFileName) - raw := "contexts:\n default:\n UNOWNED:\n name: UNOWNED\n context: default\n" - if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + if err := os.WriteFile(path, []byte(legacyIndexYAML(entries)), 0o600); err != nil { t.Fatal(err) } + return path +} - s := newLocalStore(newMapBackend(), path) - if _, err := s.Get( - testContext, - "UNOWNED", - ); err == nil || - !strings.Contains(err.Error(), "missing persisted backend ownership") { - t.Fatalf("expected missing backend ownership error, got %v", err) +func newLegacyStore(t *testing.T, entries string) (*localStore, map[Backend]*mapBackend) { + t.Helper() + backends := map[Backend]*mapBackend{ + BackendKeyring: newMapBackend(), + BackendFile: newMapBackend(), } + s := newLocalStoreWithRegistry( + BackendKeyring, + writeLegacyIndex(t, entries), + mapBackendRegistry{backends: backends}, + ) + return s, backends } -func TestLoadIndex_RejectsMissingBackendOwnership(t *testing.T) { - path := filepath.Join(t.TempDir(), IndexFileName) - raw := "contexts:\n default:\n UNOWNED:\n name: UNOWNED\n context: default\n" - if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { - t.Fatal(err) +func TestLoadIndex_ToleratesUnownedLegacySecret(t *testing.T) { + path := writeLegacyIndex( + t, + " UNOWNED:\n name: UNOWNED\n context: default\n kind: secret\n", + ) + + if _, err := loadIndex(path); err != nil { + t.Fatalf("loadIndex on legacy entry = %v, want nil", err) } +} + +func TestLoadIndex_RejectsInvalidBackend(t *testing.T) { + path := writeLegacyIndex( + t, + " BAD:\n name: BAD\n context: default\n kind: secret\n backend: vault\n", + ) if _, err := loadIndex(path); err == nil || - !strings.Contains(err.Error(), "missing persisted backend ownership") { - t.Fatalf("expected missing backend ownership error, got %v", err) + !strings.Contains(err.Error(), "invalid secrets backend") { + t.Fatalf("expected invalid backend error, got %v", err) } } -func TestStore_RejectsInlineSecretWithoutOwnership(t *testing.T) { - path := filepath.Join(t.TempDir(), IndexFileName) - raw := "contexts:\n default:\n UNOWNED:\n name: UNOWNED\n" + - " context: default\n value: leaked\n" - if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { - t.Fatal(err) +func TestStore_RepairsProvenLegacyOwnership(t *testing.T) { + s, backends := newLegacyStore( + t, + " LEGACY:\n name: LEGACY\n context: default\n kind: secret\n", + ) + backends[BackendKeyring].values[backendKey(testContext, "LEGACY")] = "recovered" + + got, err := s.Get(testContext, "LEGACY") + require.NoError(t, err) + require.Equal(t, "recovered", got) + + meta, err := s.Meta(testContext, "LEGACY") + require.NoError(t, err) + require.Equal(t, BackendKeyring, meta.Backend) + + stored, err := os.ReadFile(s.indexPath) + require.NoError(t, err) + require.Contains(t, string(stored), "backend: keyring") + + list, err := s.List(testContext) + require.NoError(t, err) + require.Len(t, list, 1) + require.False(t, list[0].Orphaned) + + require.NoError(t, s.Set(testContext, "LEGACY", "updated", KindSecret)) + got, err = s.Get(testContext, "LEGACY") + require.NoError(t, err) + require.Equal(t, "updated", got) + + require.NoError(t, s.Delete(testContext, "LEGACY")) + require.NotContains(t, backends[BackendKeyring].values, backendKey(testContext, "LEGACY")) +} + +func TestStore_UnownedValueNotFoundIsRecoverable(t *testing.T) { + s, backends := newLegacyStore( + t, + " GONE:\n name: GONE\n context: default\n kind: secret\n", + ) + + if _, err := s.Get(testContext, "GONE"); err == nil || + !strings.Contains(err.Error(), "no proven owning backend") { + t.Fatalf("expected actionable unowned error, got %v", err) } - s := newLocalStore(newMapBackend(), path) - if _, err := s.Get( - testContext, - "UNOWNED", - ); err == nil || - !strings.Contains(err.Error(), "missing persisted backend ownership") { - t.Fatalf("expected missing backend ownership error, got %v", err) + list, err := s.List(testContext) + require.NoError(t, err) + require.Len(t, list, 1) + require.True(t, list[0].Orphaned) + + require.NoError(t, s.Set(testContext, "GONE", "fresh", KindSecret)) + got, err := s.Get(testContext, "GONE") + require.NoError(t, err) + require.Equal(t, "fresh", got) + require.Equal(t, "fresh", backends[BackendKeyring].values[backendKey(testContext, "GONE")]) + + require.NoError(t, s.Delete(testContext, "GONE")) + _, err = s.Get(testContext, "GONE") + require.ErrorIs(t, err, ErrSecretNotFound) +} + +func TestStore_NeverAssignsAmbiguousOwnership(t *testing.T) { + s, backends := newLegacyStore( + t, + " BOTH:\n name: BOTH\n context: default\n kind: secret\n", + ) + backends[BackendKeyring].values[backendKey(testContext, "BOTH")] = "from-keyring" + backends[BackendFile].values[backendKey(testContext, "BOTH")] = "from-file" + + if _, err := s.Get(testContext, "BOTH"); err == nil || + !strings.Contains(err.Error(), "no proven owning backend") { + t.Fatalf("ambiguous ownership must not be guessed, got %v", err) } + meta, err := s.Meta(testContext, "BOTH") + require.NoError(t, err) + require.Equal(t, Backend(""), meta.Backend) + + require.NoError(t, s.Set(testContext, "BOTH", "explicit", KindSecret)) + got, err := s.Get(testContext, "BOTH") + require.NoError(t, err) + require.Equal(t, "explicit", got) + + require.NoError(t, s.Delete(testContext, "BOTH")) +} + +func TestStore_UnownedLegacySecretDoesNotBlockEnvWrites(t *testing.T) { + s, _ := newLegacyStore( + t, + " LEGACY:\n name: LEGACY\n context: default\n kind: secret\n", + ) + + require.NoError(t, s.Set(testContext, "MY_VAR", "plain", KindEnv)) + got, err := s.Get(testContext, "MY_VAR") + require.NoError(t, err) + require.Equal(t, "plain", got) + + list, err := s.List(testContext) + require.NoError(t, err) + require.Len(t, list, 2) +} + +func TestStore_DeleteUnownedRemovesFromEveryBackend(t *testing.T) { + s, backends := newLegacyStore( + t, + " BOTH:\n name: BOTH\n context: default\n kind: secret\n", + ) + backends[BackendKeyring].values[backendKey(testContext, "BOTH")] = "from-keyring" + backends[BackendFile].values[backendKey(testContext, "BOTH")] = "from-file" + + require.NoError(t, s.Delete(testContext, "BOTH")) + require.NotContains(t, backends[BackendKeyring].values, backendKey(testContext, "BOTH")) + require.NotContains(t, backends[BackendFile].values, backendKey(testContext, "BOTH")) + _, err := s.Get(testContext, "BOTH") + require.ErrorIs(t, err, ErrSecretNotFound) } // A key file that already exists must never be overwritten, so a second @@ -594,3 +719,72 @@ func TestStore_MetaHidesValueAndReportsKind(t *testing.T) { t.Error("Meta must not return the value") } } + +func TestStore_RepairsOwnershipWhenOtherBackendUnprobeable(t *testing.T) { + backends := map[Backend]*mapBackend{BackendFile: newMapBackend()} + backends[BackendFile].values[backendKey(testContext, "LEGACY")] = "recovered" + s := newLocalStoreWithRegistry( + BackendFile, + writeLegacyIndex( + t, + " LEGACY:\n name: LEGACY\n context: default\n kind: secret\n", + ), + mapBackendRegistry{backends: backends}, + ) + + got, err := s.Get(testContext, "LEGACY") + require.NoError(t, err) + require.Equal(t, "recovered", got) + meta, err := s.Meta(testContext, "LEGACY") + require.NoError(t, err) + require.Equal(t, BackendFile, meta.Backend) +} + +// Exercises the real registry against a legacy index and an age-encrypted +// secrets.enc written before backend ownership and key source were persisted. +func TestStore_RepairsLegacyFileBackendWithPassphrase(t *testing.T) { + t.Setenv(EnvPassphrase, "correct horse battery staple") + dir := t.TempDir() + + fk, err := openPassphraseFileKey() + require.NoError(t, err) + require.NoError(t, newFileBackend(filepath.Join(dir, EncryptedFileName), fk). + set(backendKey(testContext, "LEGACY"), "recovered")) + indexPath := filepath.Join(dir, IndexFileName) + raw := "contexts:\n default:\n LEGACY:\n name: LEGACY\n context: default\n kind: secret\n" + require.NoError(t, os.WriteFile(indexPath, []byte(raw), 0o600)) + + s := newLocalStoreWithRegistry(BackendAuto, indexPath, newSystemBackendRegistry(dir)) + got, err := s.Get(testContext, "LEGACY") + require.NoError(t, err) + require.Equal(t, "recovered", got) + + stored, err := os.ReadFile(indexPath) + require.NoError(t, err) + require.Contains(t, string(stored), "backend: file") + + require.NoError(t, s.Set(testContext, "MY_VAR", "plain", KindEnv)) + got, err = s.Get(testContext, "MY_VAR") + require.NoError(t, err) + require.Equal(t, "plain", got) +} + +// With no encrypted file and no reachable keyring, a legacy entry's value is +// nowhere; the user recovers by setting the secret again. +func TestStore_UnownedWithoutBackendStateIsRecoverable(t *testing.T) { + dir := t.TempDir() + indexPath := filepath.Join(dir, IndexFileName) + raw := "contexts:\n default:\n LEGACY:\n name: LEGACY\n context: default\n kind: secret\n" + require.NoError(t, os.WriteFile(indexPath, []byte(raw), 0o600)) + + s := newLocalStoreWithRegistry(BackendAuto, indexPath, newSystemBackendRegistry(dir)) + if _, err := s.Get(testContext, "LEGACY"); err == nil || + !strings.Contains(err.Error(), "no proven owning backend") { + t.Fatalf("expected actionable unowned error, got %v", err) + } + + require.NoError(t, s.Set(testContext, "LEGACY", "fresh", KindSecret)) + got, err := s.Get(testContext, "LEGACY") + require.NoError(t, err) + require.Equal(t, "fresh", got) +} From 76a1e6691ea11cddd350d8599ddcd55f11da24c0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 00:54:55 -0600 Subject: [PATCH 05/18] docs(secrets): explain automatic ownership repair Signed-off-by: Samuel K --- .../content/docs/developing-in-workspaces/secrets.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 3c8badc30..9d45d7f4b 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -316,6 +316,11 @@ These settings select storage only when a new Devsy-managed secret is created. After creation, the concrete backend is recorded in `secrets.yaml` and the secret stays there even if the preference changes. +Secrets created before backend ownership was recorded are repaired +automatically: Devsy checks which backend actually holds each value and records +that backend. When the value cannot be located, the entry is shown as +`Missing value`; set the secret again or delete it to resolve the entry. + The local `file` backend generates and manages an encryption key automatically. For stronger at-rest protection, set a passphrase with `DEVSY_SECRETS_PASSPHRASE`; the derived encryption key is then not stored on From 01a03ac6048b5469dbd5162015da01945d53047e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:00:19 -0600 Subject: [PATCH 06/18] refactor(secrets): remove redundant comments Signed-off-by: Samuel K --- pkg/secrets/backend_registry.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/secrets/backend_registry.go b/pkg/secrets/backend_registry.go index 6b34594f9..45d5c495e 100644 --- a/pkg/secrets/backend_registry.go +++ b/pkg/secrets/backend_registry.go @@ -13,9 +13,6 @@ import ( type backendRegistry interface { Open(kind Backend, idx *index, create bool) (backend, error) ResolveForNewSecret(preference Backend, idx *index) (Backend, error) - // Probe reports whether kind's backend holds key. conclusive is false when - // the backend cannot answer (unavailable or unreadable), so callers never - // treat an unprobeable backend as empty. Probe(kind Backend, idx *index, key string) (present, conclusive bool) } @@ -74,13 +71,10 @@ func (r *systemBackendRegistry) Probe(kind Backend, idx *index, key string) (boo case BackendFile: path := filepath.Join(r.dir, EncryptedFileName) if _, err := os.Stat(path); err != nil { - // No encrypted file means the file backend provably holds nothing. return false, true } fk, err := openExistingFileKey(r.dir, idx) if err != nil && idx.data.KeySource == "" { - // Legacy indexes may predate persisted key-source metadata; a - // configured passphrase still proves ownership read-only. fk, err = openPassphraseFileKey() } if err != nil { From c7612270f267361ae14c001fb71c9ac910b0cf27 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:00:28 -0600 Subject: [PATCH 07/18] refactor(secrets): remove redundant comments Signed-off-by: Samuel K --- pkg/secrets/index.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go index b68748c10..0721747be 100644 --- a/pkg/secrets/index.go +++ b/pkg/secrets/index.go @@ -61,10 +61,6 @@ func (i *index) normalizeKinds() { } } -// validateBackends rejects structurally invalid entries. A sensitive entry -// without a recorded backend is not invalid: indexes written before backend -// ownership was persisted are repaired by the store, which proves ownership -// from the backends themselves. func (i *index) validateBackends() error { for context, entries := range i.data.Contexts { for name, meta := range entries { From 14281daa23a90153bb56c5a14113f249d88c3643 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:00:37 -0600 Subject: [PATCH 08/18] refactor(secrets): remove redundant comments Signed-off-by: Samuel K --- pkg/secrets/local_store.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index 3b9b2ec9c..42433e200 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -295,8 +295,6 @@ func (s *localStore) persistSensitive( return err } meta.Backend = resolved - // Fresh ownership means no reachable backend state to preserve, so the - // backend may initialize its key material. create = true } b, err := s.backends.Open(meta.Backend, idx, create) @@ -339,8 +337,6 @@ func (s *localStore) checkKeySource(idx *index) error { ) } -// loadRepaired loads the index and repairs legacy entries written before -// backend ownership was persisted, persisting proven repairs best-effort. func (s *localStore) loadRepaired() (*index, error) { idx, err := loadIndex(s.indexPath) if err != nil { @@ -352,9 +348,6 @@ func (s *localStore) loadRepaired() (*index, error) { return idx, nil } -// persistRepairs rewrites the index under the store lock so ownership proven -// during a lock-free read survives; failures only delay the repair to the -// next operation. func (s *localStore) persistRepairs() { unlock, err := s.lock() if err != nil { @@ -371,12 +364,6 @@ func (s *localStore) persistRepairs() { } } -// repairLegacyOwnership assigns a backend to sensitive entries that predate -// persisted ownership. Ownership is assigned only when exactly one backend -// proves it holds the value; an entry whose value is proven in both backends, -// or in none, stays unowned rather than guessing and reading the wrong value -// later. A backend that cannot answer does not block repair: it cannot serve -// reads either, so the proven backend is the only usable source. func (s *localStore) repairLegacyOwnership(idx *index) bool { repaired := false for context, entries := range idx.data.Contexts { @@ -391,8 +378,6 @@ func (s *localStore) repairLegacyOwnership(idx *index) bool { meta.Backend = owner entries[name] = meta if owner == BackendFile && idx.data.KeySource == "" { - // The file probe could only succeed without persisted key-source - // metadata through the configured passphrase. idx.data.KeySource = string(keySourcePassphrase) } repaired = true @@ -415,8 +400,6 @@ func (s *localStore) provenOwner(idx *index, key string) (Backend, bool) { return found[0], true } -// removeFromProbeableBackends drops key from every backend that answers, -// because an unowned entry cannot name the backend holding its value. func (s *localStore) removeFromProbeableBackends(idx *index, key string) { for _, kind := range []Backend{BackendKeyring, BackendFile} { b, err := s.backends.Open(kind, idx, false) From 791c7cf4c42c7a6e925412a7f830bafa69ac88f1 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:00:45 -0600 Subject: [PATCH 09/18] refactor(secrets): remove redundant comments Signed-off-by: Samuel K --- pkg/secrets/store_internal_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index e7c1afeea..15216e7f9 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -740,8 +740,6 @@ func TestStore_RepairsOwnershipWhenOtherBackendUnprobeable(t *testing.T) { require.Equal(t, BackendFile, meta.Backend) } -// Exercises the real registry against a legacy index and an age-encrypted -// secrets.enc written before backend ownership and key source were persisted. func TestStore_RepairsLegacyFileBackendWithPassphrase(t *testing.T) { t.Setenv(EnvPassphrase, "correct horse battery staple") dir := t.TempDir() @@ -769,8 +767,6 @@ func TestStore_RepairsLegacyFileBackendWithPassphrase(t *testing.T) { require.Equal(t, "plain", got) } -// With no encrypted file and no reachable keyring, a legacy entry's value is -// nowhere; the user recovers by setting the secret again. func TestStore_UnownedWithoutBackendStateIsRecoverable(t *testing.T) { dir := t.TempDir() indexPath := filepath.Join(dir, IndexFileName) From e8a045fca90ef103802012eb59149b48164134dd Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:04:21 -0600 Subject: [PATCH 10/18] refactor(secrets): split backend probes Signed-off-by: Samuel K --- pkg/secrets/backend_registry.go | 40 ++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/pkg/secrets/backend_registry.go b/pkg/secrets/backend_registry.go index 45d5c495e..abb8ef40d 100644 --- a/pkg/secrets/backend_registry.go +++ b/pkg/secrets/backend_registry.go @@ -64,28 +64,36 @@ func (r *systemBackendRegistry) Open(kind Backend, idx *index, create bool) (bac func (r *systemBackendRegistry) Probe(kind Backend, idx *index, key string) (bool, bool) { switch kind { case BackendKeyring: - if !keyringAvailable() { - return false, false - } - return probePresence(keyringBackend{}, key) + return probeKeyring(key) case BackendFile: - path := filepath.Join(r.dir, EncryptedFileName) - if _, err := os.Stat(path); err != nil { - return false, true - } - fk, err := openExistingFileKey(r.dir, idx) - if err != nil && idx.data.KeySource == "" { - fk, err = openPassphraseFileKey() - } - if err != nil { - return false, false - } - return probePresence(newFileBackend(path, fk), key) + return r.probeFile(idx, key) default: return false, false } } +func probeKeyring(key string) (bool, bool) { + if !keyringAvailable() { + return false, false + } + return probePresence(keyringBackend{}, key) +} + +func (r *systemBackendRegistry) probeFile(idx *index, key string) (bool, bool) { + path := filepath.Join(r.dir, EncryptedFileName) + if _, err := os.Stat(path); err != nil { + return false, true + } + fk, err := openExistingFileKey(r.dir, idx) + if err != nil && idx.data.KeySource == "" { + fk, err = openPassphraseFileKey() + } + if err != nil { + return false, false + } + return probePresence(newFileBackend(path, fk), key) +} + func probePresence(b backend, key string) (present, conclusive bool) { _, err := b.get(key) switch { From 6a277616f7969867811b61bcaa815dc3b9d7756a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:04:31 -0600 Subject: [PATCH 11/18] test(secrets): allow temporary index read Signed-off-by: Samuel K --- pkg/secrets/store_internal_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index 15216e7f9..9c7b56969 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -757,7 +757,7 @@ func TestStore_RepairsLegacyFileBackendWithPassphrase(t *testing.T) { require.NoError(t, err) require.Equal(t, "recovered", got) - stored, err := os.ReadFile(indexPath) + stored, err := os.ReadFile(indexPath) // #nosec G304 -- test-owned temporary path. require.NoError(t, err) require.Contains(t, string(stored), "backend: file") From c4dd7994045f8a97b0d45fe5ef4946ac26f2ab07 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:19:54 -0600 Subject: [PATCH 12/18] refactor(secrets): order registry methods Signed-off-by: Samuel K --- pkg/secrets/backend_registry.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/secrets/backend_registry.go b/pkg/secrets/backend_registry.go index abb8ef40d..53c3d964e 100644 --- a/pkg/secrets/backend_registry.go +++ b/pkg/secrets/backend_registry.go @@ -79,21 +79,6 @@ func probeKeyring(key string) (bool, bool) { return probePresence(keyringBackend{}, key) } -func (r *systemBackendRegistry) probeFile(idx *index, key string) (bool, bool) { - path := filepath.Join(r.dir, EncryptedFileName) - if _, err := os.Stat(path); err != nil { - return false, true - } - fk, err := openExistingFileKey(r.dir, idx) - if err != nil && idx.data.KeySource == "" { - fk, err = openPassphraseFileKey() - } - if err != nil { - return false, false - } - return probePresence(newFileBackend(path, fk), key) -} - func probePresence(b backend, key string) (present, conclusive bool) { _, err := b.get(key) switch { @@ -125,6 +110,21 @@ func (r *systemBackendRegistry) ResolveForNewSecret( } } +func (r *systemBackendRegistry) probeFile(idx *index, key string) (bool, bool) { + path := filepath.Join(r.dir, EncryptedFileName) + if _, err := os.Stat(path); err != nil { + return false, true + } + fk, err := openExistingFileKey(r.dir, idx) + if err != nil && idx.data.KeySource == "" { + fk, err = openPassphraseFileKey() + } + if err != nil { + return false, false + } + return probePresence(newFileBackend(path, fk), key) +} + func openExistingFileKey(dir string, idx *index) (*fileKey, error) { source := keySource(idx.data.KeySource) if source == "" { From 062addc7b508bf6efcf6a973f86190572c90205b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 01:36:59 -0600 Subject: [PATCH 13/18] style(secrets): apply repository formatter Signed-off-by: Samuel K --- pkg/secrets/backend_registry.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/secrets/backend_registry.go b/pkg/secrets/backend_registry.go index 53c3d964e..59ad009d2 100644 --- a/pkg/secrets/backend_registry.go +++ b/pkg/secrets/backend_registry.go @@ -22,6 +22,7 @@ func (r fixedBackendRegistry) Open(_ Backend, _ *index, _ bool) (backend, error) func (r fixedBackendRegistry) ResolveForNewSecret(_ Backend, _ *index) (Backend, error) { return BackendKeyring, nil } + func (r fixedBackendRegistry) Probe(kind Backend, _ *index, key string) (bool, bool) { if kind != BackendKeyring { return false, false From 9d03db66843ec01cadef4a34f86f3adeb6aa1303 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 07:56:50 -0600 Subject: [PATCH 14/18] fix(secrets): require conclusive probes before repairing legacy ownership Signed-off-by: Samuel K --- pkg/secrets/local_store.go | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index 42433e200..613f3a197 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -185,7 +185,9 @@ func (s *localStore) Delete(context, name string) error { if meta, ok := idx.get(context, name); ok && meta.Sensitive() { if meta.Backend == "" { - s.removeFromProbeableBackends(idx, backendKey(context, name)) + if err := s.removeFromProbeableBackends(idx, backendKey(context, name)); err != nil { + return err + } } else { b, openErr := s.backends.Open(meta.Backend, idx, false) if openErr != nil { @@ -315,8 +317,7 @@ func (s *localStore) removeSensitive(idx *index, meta *SecretMeta) error { return err } if meta.Backend == "" { - s.removeFromProbeableBackends(idx, backendKey(meta.Context, meta.Name)) - return nil + return s.removeFromProbeableBackends(idx, backendKey(meta.Context, meta.Name)) } b, err := s.backends.Open(meta.Backend, idx, false) if err != nil { @@ -389,7 +390,10 @@ func (s *localStore) repairLegacyOwnership(idx *index) bool { func (s *localStore) provenOwner(idx *index, key string) (Backend, bool) { var found []Backend for _, kind := range []Backend{BackendKeyring, BackendFile} { - present, _ := s.backends.Probe(kind, idx, key) + present, conclusive := s.backends.Probe(kind, idx, key) + if !conclusive { + return "", false + } if present { found = append(found, kind) } @@ -400,14 +404,27 @@ func (s *localStore) provenOwner(idx *index, key string) (Backend, bool) { return found[0], true } -func (s *localStore) removeFromProbeableBackends(idx *index, key string) { +func (s *localStore) removeFromProbeableBackends(idx *index, key string) error { + var present []Backend for _, kind := range []Backend{BackendKeyring, BackendFile} { + found, conclusive := s.backends.Probe(kind, idx, key) + if !conclusive { + return fmt.Errorf("cannot probe secrets backend %q", kind) + } + if found { + present = append(present, kind) + } + } + for _, kind := range present { b, err := s.backends.Open(kind, idx, false) if err != nil { - continue + return err + } + if err := b.remove(key); err != nil { + return err } - _ = b.remove(key) } + return nil } func unownedSecretError(context, name string) error { From b8db1d4664189aaccedd348e1ad00a3df715b3b6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 07:57:30 -0600 Subject: [PATCH 15/18] fix(secrets): treat only a missing encrypted file as conclusive absence Signed-off-by: Samuel K --- pkg/secrets/backend_registry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/secrets/backend_registry.go b/pkg/secrets/backend_registry.go index 59ad009d2..820c7b06f 100644 --- a/pkg/secrets/backend_registry.go +++ b/pkg/secrets/backend_registry.go @@ -114,7 +114,7 @@ func (r *systemBackendRegistry) ResolveForNewSecret( func (r *systemBackendRegistry) probeFile(idx *index, key string) (bool, bool) { path := filepath.Join(r.dir, EncryptedFileName) if _, err := os.Stat(path); err != nil { - return false, true + return false, errors.Is(err, os.ErrNotExist) } fk, err := openExistingFileKey(r.dir, idx) if err != nil && idx.data.KeySource == "" { From 889d27c1dd0548df70c29abf6b391bfe69988fa5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 07:57:48 -0600 Subject: [PATCH 16/18] test(secrets): expect unresolved ownership while a backend is unprobeable Signed-off-by: Samuel K --- pkg/secrets/store_internal_test.go | 46 +++++++++++++++++++----------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index 9c7b56969..c80758531 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -732,15 +732,33 @@ func TestStore_RepairsOwnershipWhenOtherBackendUnprobeable(t *testing.T) { mapBackendRegistry{backends: backends}, ) - got, err := s.Get(testContext, "LEGACY") - require.NoError(t, err) - require.Equal(t, "recovered", got) + _, err := s.Get(testContext, "LEGACY") + require.Error(t, err) + require.Contains(t, err.Error(), "no proven owning backend") meta, err := s.Meta(testContext, "LEGACY") require.NoError(t, err) - require.Equal(t, BackendFile, meta.Backend) + require.Equal(t, Backend(""), meta.Backend) +} + +func TestStore_DeleteUnownedFailsWhenBackendUnprobeable(t *testing.T) { + backends := map[Backend]*mapBackend{BackendFile: newMapBackend()} + backends[BackendFile].values[backendKey(testContext, "LEGACY")] = "sensitive" + s := newLocalStoreWithRegistry( + BackendFile, + writeLegacyIndex( + t, + " LEGACY:\n name: LEGACY\n context: default\n kind: secret\n", + ), + mapBackendRegistry{backends: backends}, + ) + + require.Error(t, s.Delete(testContext, "LEGACY")) + require.Equal(t, "sensitive", backends[BackendFile].values[backendKey(testContext, "LEGACY")]) + _, err := s.Meta(testContext, "LEGACY") + require.NoError(t, err) } -func TestStore_RepairsLegacyFileBackendWithPassphrase(t *testing.T) { +func TestProbeFileReadsLegacyPassphraseBackend(t *testing.T) { t.Setenv(EnvPassphrase, "correct horse battery staple") dir := t.TempDir() @@ -751,20 +769,14 @@ func TestStore_RepairsLegacyFileBackendWithPassphrase(t *testing.T) { indexPath := filepath.Join(dir, IndexFileName) raw := "contexts:\n default:\n LEGACY:\n name: LEGACY\n context: default\n kind: secret\n" require.NoError(t, os.WriteFile(indexPath, []byte(raw), 0o600)) - - s := newLocalStoreWithRegistry(BackendAuto, indexPath, newSystemBackendRegistry(dir)) - got, err := s.Get(testContext, "LEGACY") + idx, err := loadIndex(indexPath) require.NoError(t, err) - require.Equal(t, "recovered", got) - - stored, err := os.ReadFile(indexPath) // #nosec G304 -- test-owned temporary path. - require.NoError(t, err) - require.Contains(t, string(stored), "backend: file") - require.NoError(t, s.Set(testContext, "MY_VAR", "plain", KindEnv)) - got, err = s.Get(testContext, "MY_VAR") - require.NoError(t, err) - require.Equal(t, "plain", got) + registry, ok := newSystemBackendRegistry(dir).(*systemBackendRegistry) + require.True(t, ok) + present, conclusive := registry.probeFile(idx, backendKey(testContext, "LEGACY")) + require.True(t, present) + require.True(t, conclusive) } func TestStore_UnownedWithoutBackendStateIsRecoverable(t *testing.T) { From 136c3a15b0676b4b03cdf8375256c87269dcc0ac Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 07:58:13 -0600 Subject: [PATCH 17/18] docs(secrets): document all unresolved legacy ownership cases Signed-off-by: Samuel K --- .../content/docs/developing-in-workspaces/secrets.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 9d45d7f4b..93b08a3c2 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -318,8 +318,11 @@ secret stays there even if the preference changes. Secrets created before backend ownership was recorded are repaired automatically: Devsy checks which backend actually holds each value and records -that backend. When the value cannot be located, the entry is shown as -`Missing value`; set the secret again or delete it to resolve the entry. +that backend, but only when every backend answers conclusively and exactly one +contains the value. The entry is shown as `Missing value` when the value is +found in more than one backend, when a backend is unavailable, or when the +value cannot be located; set the secret again or delete it to resolve the +entry. The local `file` backend generates and manages an encryption key automatically. For stronger at-rest protection, set a passphrase with From 5c1f6b9e31a6a55da4f51554d23770a1e56b3371 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 08:16:05 -0600 Subject: [PATCH 18/18] refactor(secrets): extract secret value removal for lint parity Signed-off-by: Samuel K --- pkg/secrets/local_store.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index 613f3a197..587740575 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -184,18 +184,8 @@ func (s *localStore) Delete(context, name string) error { s.repairLegacyOwnership(idx) if meta, ok := idx.get(context, name); ok && meta.Sensitive() { - if meta.Backend == "" { - if err := s.removeFromProbeableBackends(idx, backendKey(context, name)); err != nil { - return err - } - } else { - b, openErr := s.backends.Open(meta.Backend, idx, false) - if openErr != nil { - return openErr - } - if err := b.remove(backendKey(context, name)); err != nil { - return err - } + if err := s.removeSecretValue(idx, meta); err != nil { + return err } } idx.remove(context, name) @@ -404,6 +394,18 @@ func (s *localStore) provenOwner(idx *index, key string) (Backend, bool) { return found[0], true } +func (s *localStore) removeSecretValue(idx *index, meta SecretMeta) error { + key := backendKey(meta.Context, meta.Name) + if meta.Backend == "" { + return s.removeFromProbeableBackends(idx, key) + } + b, err := s.backends.Open(meta.Backend, idx, false) + if err != nil { + return err + } + return b.remove(key) +} + func (s *localStore) removeFromProbeableBackends(idx *index, key string) error { var present []Backend for _, kind := range []Backend{BackendKeyring, BackendFile} {