Skip to content

feat(secret-store): add generic OS keyring secret store to OSS core#2

Open
GregKinne wants to merge 19 commits into
mainfrom
feat/oss-secret-store
Open

feat(secret-store): add generic OS keyring secret store to OSS core#2
GregKinne wants to merge 19 commits into
mainfrom
feat/oss-secret-store

Conversation

@GregKinne

Copy link
Copy Markdown
Owner

Upstream a secrets manager into OSS Code Puppy

Adds a generic OS keyring secret store to OSS core.

Mechanism only — it migrates no existing secrets and ships no secret values.

What's in this PR

  • code_puppy/secret_store.py (new) — generic secrets manager:
    • Public API: get_secret / set_secret / delete_secret / keyring_available.
    • Keyring-first; falls back to a permission-hardened JSON file only when
      no keyring backend is available (headless / CI).
  • pyproject.toml — adds keyring>=24.0.0 (and the corresponding
    uv.lock update). Does not add msal-extensions.
  • tests/test_secret_store.py (new) — 20 tests covering the three
    required paths: keyring available, keyring missing, and fallback file.

Design decisions

  • Runtime backend absence is the real concern, and it's handled:
    keyring imports fine but can still raise at runtime on a headless box
    with no backend. keyring_available() detects this and the get/set/
    delete paths degrade to the file fallback instead of crashing.
  • Fallback is secure by construction0o600 JSON, atomic write
    (temp + fchmod + os.replace), permission-repair on read, and a
    one-shot warning that fallback storage is active (per CP-020 audit
    guidance: never silently fall back to plaintext).

Verification

  • pytest tests/test_secret_store.py20 passed.
  • ruff check → clean.
  • Branched from a fresh upstream/main; fork main fast-forwarded to
    upstream first so this diff is a single clean commit.

Introduce code_puppy/secret_store.py, a generic secrets manager that
reads/writes via the OS keyring with a permission-hardened JSON file
fallback for headless and CI environments.

Public API: get_secret / set_secret / delete_secret / keyring_available.

- keyring is a hard dependency (plain import, no ImportError guard); the
  packages we ship always include it. Runtime backend absence (headless/
  CI) is detected via keyring_available() and degrades to the fallback.
- Fallback writes 0o600 JSON atomically (temp + fchmod + os.replace),
  repairs loose permissions on read, and warns once per process that
  fallback storage is active.
- Ships no secret values and holds no product-specific key names or
  migration logic. Migrating specific secrets (puppy_token, API keys)
  onto this store is deferred to follow-up work, one category per PR.

Adds keyring>=24.0.0 to pyproject. 20 unit tests cover the three paths:
keyring available, keyring missing, and fallback file behavior.
@GregKinne
GregKinne force-pushed the feat/oss-secret-store branch from 3dd2c04 to adf8074 Compare July 3, 2026 15:22
Gregory Kinne and others added 16 commits July 3, 2026 13:44
Store all secrets in a single macOS Keychain item so the user sees at
most one Keychain access prompt regardless of secret count.

Why
- Each Keychain item's ACL is anchored to the calling binary's identity.
  uv's managed CPython is ad-hoc signed (no stable Team ID), so its
  identity is its cdhash, which changes on every Python version bump.
  That invalidates the ACL and prompts the user once per keychain item.
  With ~12 secrets landing across upcoming subtasks, that is a dozen
  "Always Allow" dialogs after a routine upgrade. One item means one ACL
  and therefore one prompt.

Design
- New code_puppy/secret_store_backends.py: ConsolidatedKeychainBackend, a
  custom keyring backend that stores a service's secrets as one JSON blob
  under a single (service, __blob__) keychain item, delegating actual
  keychain I/O to the stock macOS backend.
- Darwin-gated: should_use_consolidated_backend() returns True only on
  macOS, only when the active backend is the stock macOS one, and only
  when the user has not pinned a backend (PYTHON_KEYRING_BACKEND). Off
  macOS the native keyring backend is used unchanged.
- Selection is explicit via keyring.set_keyring. priority is set BELOW
  the stock backend (0.5 < 5) so keyring's automatic priority-based
  discovery never picks this backend on any platform; the Darwin gate is
  the single source of truth. The macOS delegate is constructed lazily so
  merely importing/instantiating the class during cross-platform backend
  discovery never touches macOS-only code.
- secret_store installs the backend lazily and once, on first secret op,
  via _ensure_backend(). Best-effort: any failure leaves the native
  backend in place.

Concurrency + corruption safety
- set/delete hold an in-process lock plus an advisory flock on a lock
  file beside CONFIG_DIR, covering the full read-modify-write so parallel
  sessions / MCP subprocesses cannot clobber each other's writes.
- A corrupt or non-dict blob raises rather than being treated as empty,
  so a bad read can never silently overwrite every stored secret.

Public API unchanged: get_secret / set_secret / delete_secret /
keyring_available are identical on every platform. Consolidation is a
backend concern callers never observe.

Tests: gating (darwin/stock/user-pinned), blob storage (N secrets share
one item, distinct services get distinct items), corruption safety, and
best-effort install. Verified end-to-end against the real macOS Keychain
(3 secrets -> one item, surgical delete). 35 passed, ruff clean.
Three minimalist bug fixes for the secret store introduced in PR #2:

1. _ensure_backend: catch ImportError when secret_store_backends is
   unimportable (Windows has no fcntl module). The consolidated macOS
   backend is irrelevant off macOS anyway.

2. _write_fallback: use hasattr(os, 'fchmod') guard and fall back to
   os.chmod on the path. os.fchmod is POSIX-only and raises
   AttributeError on Windows, which the existing except OSError would
   not catch.

3. set_secret: when the keyring backend is healthy but the write fails
   (transient error, dismissed Keychain prompt, etc.), warn instead of
   silently writing to the fallback file. The old behavior stranded the
   secret because get_secret skips the fallback when keyring_available()
   is True.

Tests added for all three cases.
The fallback path exists for environments without a keyring (headless
Linux, CI, Windows). Using os.chmod instead of os.fchmod removes the
hasattr guard from the prior commit — one cross-platform call instead
of a branching shim. Simpler test, same behavior.
…ervice name

Windows Credential Manager encodes blobs as UTF-16-LE and caps them at
~2,560 bytes.  Long tokens (e.g. JWTs) routinely exceed this, causing
silent write failures (error 1783).

Changes:
- Split oversized secrets into <=1,200-char chunks, stored as numbered
  keyring entries with an atomic count marker.  Reads reassemble
  transparently; pre-chunking entries remain readable.
- Three-tier write strategy: direct keyring -> chunked keyring -> 0o600
  file fallback.  get_secret always checks the fallback as a last resort
  so secrets written there by a prior session are still recoverable.
- Add configure_service_name() so downstream distributions can namespace
  their secrets without bleeding across builds.
- Add sys.platform == 'win32' skipif decorators on POSIX permission
  tests and an fcntl collection guard on the backends test module.
- 52 tests pass (9 new chunking + 4 new service-name tests).
…(F9)

Caller-supplied names containing ':cp:' could shadow a real secret's chunk
metadata or, via delete_secret, destroy an unrelated entry. Add _validate_name
(also rejecting empty names) and call it at the top of get/set/delete_secret.
Introduces SecretStoreError for later failure-contract work.

Addresses review finding: discussion_r3554814765
… (F8)

Two related normalization bugs:
- set_secret('token', '  ') hit an early return, so _keyring_set reported
  False -- indistinguishable from a real backend failure -- and set_secret
  then warned misleadingly about a 'healthy backend' write failure.
- Universal .strip() on write and read silently mutated secrets that carry
  legitimate leading/trailing whitespace.

Now: _validate_value rejects empty/whitespace-only input with ValueError at
the public boundary, and all secret payloads are stored/returned verbatim.
Stripping is confined to config values (service name) and int() parsing of
the chunk-count marker, which tolerates whitespace anyway.

Addresses review finding: discussion_r3554814761
…lers (F3/F4/F10)

F3: tempfile.mkstemp() sat outside the try, so its failure raised a raw
OSError while every other failure returned False. Moved it inside the try
so _write_fallback has one uniform False-on-any-failure contract.

F4: set_secret() ignored the _write_fallback() result and returned normally
after losing the credential. It now raises SecretStoreError when the keyring
is unavailable AND the fallback write fails, so a read-only/full filesystem
can no longer masquerade as success.

F10: delete_secret() had the same swallowed-return bug -- worse for a delete,
since 'delete' would report success while the plaintext secret survived. It
now raises SecretStoreError when a required scrub write fails (and stays
silent when there was nothing to scrub).

Addresses review findings: discussion_r3554790360, discussion_r3554790363,
and the delete_secret note in pullrequestreview-4666753894
…hy set (F5/F6)

F5: the fallback read-modify-write was unlocked, so two processes (main app
+ MCP subprocess) could each read the document, add a different key, and the
second writer would clobber the first (lost update). Added a cross-platform
advisory lock (fcntl on POSIX, msvcrt on Windows -- the platform where the
chunked path actually activates) plus an in-process thread lock, wrapping the
entire read->mutate->write cycle via new _fallback_set/_fallback_delete
helpers.

F6: a successful keyring write never scrubbed the fallback, so a rotated-out
secret lingered in plaintext forever and could be silently resurrected if the
keyring entry later vanished. set_secret now calls _fallback_scrub(name) after
a healthy keyring write (best-effort under the same lock; warns but does not
fail the set, since the keyring already holds the source of truth).

Addresses review findings: discussion_r3554790366, discussion_r3554814753
The keyring tier scoped entries by _service_name, but the fallback was a flat
{name: value} dict indexed only by secret name. In fallback mode, distribution
A could read, overwrite, or delete distribution B's secrets -- the isolation
promised by configure_service_name() did not hold for the file tier.

The on-disk shape is now {service_name: {name: value}}. _read_fallback_doc()
reads the full nested document (and transparently migrates a legacy flat file
under the default 'code-puppy' service so historical secrets stay readable
without leaking into another namespace). _read_fallback() returns just the
current service's slice, and the locked _fallback_set/_delete/_scrub helpers
mutate only that slice, pruning empty service buckets.

Addresses review finding: discussion_r3554790358
…chmod (F1)

The fallback warning claimed 'permission-hardened ... (mode 0o600)', but
chmod(0o600) does not establish an owner-only NTFS DACL on Windows -- it only
toggles the read-only attribute. On the older/stripped-down Windows machines
where this fallback actually activates, the file was effectively plaintext
with default inheritance while the warning implied it was protected. The tests
also skipped permission verification on Windows.

Now _harden_permissions branches by platform: chmod 0o600 on POSIX, and an
owner-only NTFS DACL via icacls (/inheritance:r + /grant:r <user>:F, no extra
dependency) on Windows. The outcome is recorded so _warn_fallback_active tells
the truth: it states the NTFS ACL when applied, and warns the file is PLAINTEXT
when icacls is unavailable. Module docstring updated to match. The Windows path
is now unit-tested (mocked icacls) instead of skipped.

Addresses review finding: discussion_r3554790356
…s (F11)

warnings.warn is effectively invisible inside the TUI and is deduplicated by
the default warnings filter, so the headless-fallback and write-failure
notices never reached a human. Added _notify(message) which emits through
code_puppy.messaging.emit_warning (imported lazily to avoid an import cycle)
and falls back to warnings.warn only when the bus is unavailable, so a notice
is never silently dropped. The three user-facing call sites now use _notify.

Tests capture bus notices via an autouse fixture instead of pytest.warns.

Addresses review finding: the warnings.warn note in pullrequestreview-4666753894
The old chunked overwrite mutated the previous value's chunks in place while
the count key still pointed at them, so a mid-overwrite crash could yield a
'Frankenstein' value; prune-before-commit could leave count=N with chunks
missing; and the only cross-process lock lived in the macOS backend while
chunking activates on Windows, where no such lock exists.

Reworked to the generation-numbered scheme the reviewer proposed:
  1. Each chunked write picks a fresh random generation and writes chunks
     under name:cp:<gen>:<i>. The previously committed value is untouched.
  2. Commit is a single atomic flip of the pointer key to '<gen>:<count>'.
     Before the flip the old value is fully readable; after it, the new value
     is -- there is no window exposing a torn/mixed value, and no lock is
     required for crash-safety.
  3. The old generation (and any stale direct/legacy entries) are GC'd best
     effort after commit.

Random (not incrementing) generations mean two concurrent writers never share
a chunk namespace; the atomic pointer flip picks the winner. Reads and deletes
understand both the new '<gen>:<count>' pointer and the legacy bare-count
layout, so pre-existing chunked secrets keep working.

Addresses review finding: discussion_r3554814758
Address review feedback on secret store (PR mpfaffenberger#531)
@GregKinne
GregKinne force-pushed the feat/oss-secret-store branch from c26607a to a58479f Compare July 10, 2026 12:52
Gregory Kinne added 2 commits July 10, 2026 10:04
Keep the OSS/enterprise secret store byte-identical while removing
corporate tells: reword the _BLOB_ACCOUNT comment to 'different
distributions' and use neutral service names in the distinct-services
backend test.

(cherry picked from commit 96da4dd4d526b42ab16fd638de688a82c1853637)
…nt null-backend silent credential loss

set_secret() called _keyring_set() unconditionally before consulting
keyring_available(). keyring.backends.null.Keyring (priority -1) has a
silent no-op set_password, so the write appeared successful while the
credential was silently discarded -- and then the fallback was scrubbed,
guaranteeing loss.

Now the keyring attempt is gated on keyring_available(): priority <= 0
backends (null/fail/headless) route straight to the permission-hardened
file fallback instead of trusting an accept-and-discard backend.

Tests: new null_keyring fixture (accept-and-discard, distinct from the
raises-based missing_keyring), TestNullBackendNoSilentLoss (5 tests) +
TestFailBackendFallsBack (1 test). 77 passed, ruff clean.
GregKinne added a commit that referenced this pull request Jul 22, 2026
* feat(secret-store): add generic OS keyring secret store to OSS core

Introduce code_puppy/secret_store.py, a generic secrets manager that
reads/writes via the OS keyring with a permission-hardened JSON file
fallback for headless and CI environments.

Public API: get_secret / set_secret / delete_secret / keyring_available.

- keyring is a hard dependency (plain import, no ImportError guard); the
  packages we ship always include it. Runtime backend absence (headless/
  CI) is detected via keyring_available() and degrades to the fallback.
- Fallback writes 0o600 JSON atomically (temp + fchmod + os.replace),
  repairs loose permissions on read, and warns once per process that
  fallback storage is active.
- Ships no secret values and holds no product-specific key names or
  migration logic. Migrating specific secrets (puppy_token, API keys)
  onto this store is deferred to follow-up work, one category per PR.

Adds keyring>=24.0.0 to pyproject. 20 unit tests cover the three paths:
keyring available, keyring missing, and fallback file behavior.

* feat(secret-store): add consolidated macOS keyring backend

Store all secrets in a single macOS Keychain item so the user sees at
most one Keychain access prompt regardless of secret count.

Why
- Each Keychain item's ACL is anchored to the calling binary's identity.
  uv's managed CPython is ad-hoc signed (no stable Team ID), so its
  identity is its cdhash, which changes on every Python version bump.
  That invalidates the ACL and prompts the user once per keychain item.
  With ~12 secrets landing across upcoming subtasks, that is a dozen
  "Always Allow" dialogs after a routine upgrade. One item means one ACL
  and therefore one prompt.

Design
- New code_puppy/secret_store_backends.py: ConsolidatedKeychainBackend, a
  custom keyring backend that stores a service's secrets as one JSON blob
  under a single (service, __blob__) keychain item, delegating actual
  keychain I/O to the stock macOS backend.
- Darwin-gated: should_use_consolidated_backend() returns True only on
  macOS, only when the active backend is the stock macOS one, and only
  when the user has not pinned a backend (PYTHON_KEYRING_BACKEND). Off
  macOS the native keyring backend is used unchanged.
- Selection is explicit via keyring.set_keyring. priority is set BELOW
  the stock backend (0.5 < 5) so keyring's automatic priority-based
  discovery never picks this backend on any platform; the Darwin gate is
  the single source of truth. The macOS delegate is constructed lazily so
  merely importing/instantiating the class during cross-platform backend
  discovery never touches macOS-only code.
- secret_store installs the backend lazily and once, on first secret op,
  via _ensure_backend(). Best-effort: any failure leaves the native
  backend in place.

Concurrency + corruption safety
- set/delete hold an in-process lock plus an advisory flock on a lock
  file beside CONFIG_DIR, covering the full read-modify-write so parallel
  sessions / MCP subprocesses cannot clobber each other's writes.
- A corrupt or non-dict blob raises rather than being treated as empty,
  so a bad read can never silently overwrite every stored secret.

Public API unchanged: get_secret / set_secret / delete_secret /
keyring_available are identical on every platform. Consolidation is a
backend concern callers never observe.

Tests: gating (darwin/stock/user-pinned), blob storage (N secrets share
one item, distinct services get distinct items), corruption safety, and
best-effort install. Verified end-to-end against the real macOS Keychain
(3 secrets -> one item, surgical delete). 35 passed, ruff clean.

* fix(secret-store): cross-platform guards + stranded-secret warning

Three minimalist bug fixes for the secret store introduced in PR #2:

1. _ensure_backend: catch ImportError when secret_store_backends is
   unimportable (Windows has no fcntl module). The consolidated macOS
   backend is irrelevant off macOS anyway.

2. _write_fallback: use hasattr(os, 'fchmod') guard and fall back to
   os.chmod on the path. os.fchmod is POSIX-only and raises
   AttributeError on Windows, which the existing except OSError would
   not catch.

3. set_secret: when the keyring backend is healthy but the write fails
   (transient error, dismissed Keychain prompt, etc.), warn instead of
   silently writing to the fallback file. The old behavior stranded the
   secret because get_secret skips the fallback when keyring_available()
   is True.

Tests added for all three cases.

* refactor: replace os.fchmod with os.chmod for OS-agnostic fallback

The fallback path exists for environments without a keyring (headless
Linux, CI, Windows). Using os.chmod instead of os.fchmod removes the
hasattr guard from the prior commit — one cross-platform call instead
of a branching shim. Simpler test, same behavior.

* refactor: transparent chunking + three-tier fallback + configurable service name

Windows Credential Manager encodes blobs as UTF-16-LE and caps them at
~2,560 bytes.  Long tokens (e.g. JWTs) routinely exceed this, causing
silent write failures (error 1783).

Changes:
- Split oversized secrets into <=1,200-char chunks, stored as numbered
  keyring entries with an atomic count marker.  Reads reassemble
  transparently; pre-chunking entries remain readable.
- Three-tier write strategy: direct keyring -> chunked keyring -> 0o600
  file fallback.  get_secret always checks the fallback as a last resort
  so secrets written there by a prior session are still recoverable.
- Add configure_service_name() so downstream distributions can namespace
  their secrets without bleeding across builds.
- Add sys.platform == 'win32' skipif decorators on POSIX permission
  tests and an fcntl collection guard on the backends test module.
- 52 tests pass (9 new chunking + 4 new service-name tests).

* style: apply ruff format to secret_store and tests

* fix(secret-store): enforce reserved ':cp:' namespace on secret names (F9)

Caller-supplied names containing ':cp:' could shadow a real secret's chunk
metadata or, via delete_secret, destroy an unrelated entry. Add _validate_name
(also rejecting empty names) and call it at the top of get/set/delete_secret.
Introduces SecretStoreError for later failure-contract work.

Addresses review finding: discussion_r3554814765

* fix(secret-store): stop stripping secrets; reject empty values loudly (F8)

Two related normalization bugs:
- set_secret('token', '  ') hit an early return, so _keyring_set reported
  False -- indistinguishable from a real backend failure -- and set_secret
  then warned misleadingly about a 'healthy backend' write failure.
- Universal .strip() on write and read silently mutated secrets that carry
  legitimate leading/trailing whitespace.

Now: _validate_value rejects empty/whitespace-only input with ValueError at
the public boundary, and all secret payloads are stored/returned verbatim.
Stripping is confined to config values (service name) and int() parsing of
the chunk-count marker, which tolerates whitespace anyway.

Addresses review finding: discussion_r3554814761

* fix(secret-store): uniform fallback failure contract, surfaced to callers (F3/F4/F10)

F3: tempfile.mkstemp() sat outside the try, so its failure raised a raw
OSError while every other failure returned False. Moved it inside the try
so _write_fallback has one uniform False-on-any-failure contract.

F4: set_secret() ignored the _write_fallback() result and returned normally
after losing the credential. It now raises SecretStoreError when the keyring
is unavailable AND the fallback write fails, so a read-only/full filesystem
can no longer masquerade as success.

F10: delete_secret() had the same swallowed-return bug -- worse for a delete,
since 'delete' would report success while the plaintext secret survived. It
now raises SecretStoreError when a required scrub write fails (and stays
silent when there was nothing to scrub).

Addresses review findings: discussion_r3554790360, discussion_r3554790363,
and the delete_secret note in pullrequestreview-4666753894

* fix(secret-store): lock fallback RMW + scrub stale plaintext on healthy set (F5/F6)

F5: the fallback read-modify-write was unlocked, so two processes (main app
+ MCP subprocess) could each read the document, add a different key, and the
second writer would clobber the first (lost update). Added a cross-platform
advisory lock (fcntl on POSIX, msvcrt on Windows -- the platform where the
chunked path actually activates) plus an in-process thread lock, wrapping the
entire read->mutate->write cycle via new _fallback_set/_fallback_delete
helpers.

F6: a successful keyring write never scrubbed the fallback, so a rotated-out
secret lingered in plaintext forever and could be silently resurrected if the
keyring entry later vanished. set_secret now calls _fallback_scrub(name) after
a healthy keyring write (best-effort under the same lock; warns but does not
fail the set, since the keyring already holds the source of truth).

Addresses review findings: discussion_r3554790366, discussion_r3554814753

* fix(secret-store): namespace the fallback file by service name (F2)

The keyring tier scoped entries by _service_name, but the fallback was a flat
{name: value} dict indexed only by secret name. In fallback mode, distribution
A could read, overwrite, or delete distribution B's secrets -- the isolation
promised by configure_service_name() did not hold for the file tier.

The on-disk shape is now {service_name: {name: value}}. _read_fallback_doc()
reads the full nested document (and transparently migrates a legacy flat file
under the default 'code-puppy' service so historical secrets stay readable
without leaking into another namespace). _read_fallback() returns just the
current service's slice, and the locked _fallback_set/_delete/_scrub helpers
mutate only that slice, pruning empty service buckets.

Addresses review finding: discussion_r3554790358

* fix(secret-store): honest owner-only hardening on Windows, not false chmod (F1)

The fallback warning claimed 'permission-hardened ... (mode 0o600)', but
chmod(0o600) does not establish an owner-only NTFS DACL on Windows -- it only
toggles the read-only attribute. On the older/stripped-down Windows machines
where this fallback actually activates, the file was effectively plaintext
with default inheritance while the warning implied it was protected. The tests
also skipped permission verification on Windows.

Now _harden_permissions branches by platform: chmod 0o600 on POSIX, and an
owner-only NTFS DACL via icacls (/inheritance:r + /grant:r <user>:F, no extra
dependency) on Windows. The outcome is recorded so _warn_fallback_active tells
the truth: it states the NTFS ACL when applied, and warns the file is PLAINTEXT
when icacls is unavailable. Module docstring updated to match. The Windows path
is now unit-tested (mocked icacls) instead of skipped.

Addresses review finding: discussion_r3554790356

* fix(secret-store): route user-facing notices through the messaging bus (F11)

warnings.warn is effectively invisible inside the TUI and is deduplicated by
the default warnings filter, so the headless-fallback and write-failure
notices never reached a human. Added _notify(message) which emits through
code_puppy.messaging.emit_warning (imported lazily to avoid an import cycle)
and falls back to warnings.warn only when the bus is unavailable, so a notice
is never silently dropped. The three user-facing call sites now use _notify.

Tests capture bus notices via an autouse fixture instead of pytest.warns.

Addresses review finding: the warnings.warn note in pullrequestreview-4666753894

* fix(secret-store): generation-numbered crash-safe chunking (F7)

The old chunked overwrite mutated the previous value's chunks in place while
the count key still pointed at them, so a mid-overwrite crash could yield a
'Frankenstein' value; prune-before-commit could leave count=N with chunks
missing; and the only cross-process lock lived in the macOS backend while
chunking activates on Windows, where no such lock exists.

Reworked to the generation-numbered scheme the reviewer proposed:
  1. Each chunked write picks a fresh random generation and writes chunks
     under name:cp:<gen>:<i>. The previously committed value is untouched.
  2. Commit is a single atomic flip of the pointer key to '<gen>:<count>'.
     Before the flip the old value is fully readable; after it, the new value
     is -- there is no window exposing a torn/mixed value, and no lock is
     required for crash-safety.
  3. The old generation (and any stale direct/legacy entries) are GC'd best
     effort after commit.

Random (not incrementing) generations mean two concurrent writers never share
a chunk namespace; the atomic pointer flip picks the winner. Reads and deletes
understand both the new '<gen>:<count>' pointer and the legacy bare-count
layout, so pre-existing chunked secrets keep working.

Addresses review finding: discussion_r3554814758

* scrub: genericize enterprise references in secret store backend

Keep the OSS/enterprise secret store byte-identical while removing
corporate tells: reword the _BLOB_ACCOUNT comment to 'different
distributions' and use neutral service names in the distinct-services
backend test.

(cherry picked from commit 96da4dd4d526b42ab16fd638de688a82c1853637)

* fix(secret_store): gate keyring write on keyring_available() to prevent null-backend silent credential loss

set_secret() called _keyring_set() unconditionally before consulting
keyring_available(). keyring.backends.null.Keyring (priority -1) has a
silent no-op set_password, so the write appeared successful while the
credential was silently discarded -- and then the fallback was scrubbed,
guaranteeing loss.

Now the keyring attempt is gated on keyring_available(): priority <= 0
backends (null/fail/headless) route straight to the permission-hardened
file fallback instead of trusting an accept-and-discard backend.

Tests: new null_keyring fixture (accept-and-discard, distinct from the
raises-based missing_keyring), TestNullBackendNoSilentLoss (5 tests) +
TestFailBackendFallsBack (1 test). 77 passed, ruff clean.

---------

Co-authored-by: Gregory Kinne <gregory.kinne@walmart.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant