Skip to content

fix(storage): harden the KVS-backed identity and wallet stores - #2408

Open
AkramBitar wants to merge 2 commits into
mainfrom
2041-kvs-hardening
Open

AkramBitar wants to merge 2 commits into
mainfrom
2041-kvs-hardening

Conversation

@AkramBitar

@AkramBitar AkramBitar commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2041

Six fixes in token/services/storage/db/kvs:

  1. Vault key collisions — composite-key components are percent-escaped, so an empty component, a / inside one (base64 identity hashes have them) or a ./.. can no longer make two different keys point at the same secret.
  2. Iterator — a key deleted between the list and its read is skipped, not yielded as an empty record.
  3. StoreIdentity — the entry IdentityExists reads is written last and every write is idempotent, so a partial failure reads as "not stored" and a retry converges.
  4. tracker.go — history entries are snapshots, not the caller's pointer.
  5. fscKVS.Close() — returns the store's close error instead of only logging it.
  6. Test helper — closes the HTTP response body on success.

Also fixed in the same code: a nil iterator for an empty list (panicked in IdentityConfigurationsIterator), an unchecked value.(string), and an unclosed iterator in GetWalletIDs.

Warning

Storage-format change: Vault entries whose key components contain / or % now live at a different path and are not found by the new code. See docs/services/storage/kvs.md.

IdentityExists returns (bool, error)

A second commit adopts the WalletStoreService.IdentityExists signature from #2409, so a failed lookup is no longer indistinguishable from "the binding does not exist". The KVS store previously swallowed the composite-key error and answered false; it now wraps it. Only key construction can be reported that way, because this backend's Exists is FSC's GetExisting, which drops the underlying store error — the caveat already documented on GetWalletID.

Registry.ContainsIdentity cannot grow an error return (it backs driver.Wallet.Contains, a plain bool), so it logs the failure before reporting false.

Note

This duplicates the IdentityExists slice of #2409 verbatim — #2409 already updates kvs/walletdb.go itself, so taking the signature here also means taking the interface, sql/common/wallet.go, the mock and the test callers. The overlapping hunks are byte-identical, so whichever PR merges second drops them as already-applied rather than conflicting. If #2409 lands first, this commit can simply be dropped.

The issue's perf suggestion (hash-first keys) is left out — it needs a secondary index plus a scan fallback for existing data, so it belongs in its own change.

Tests: unit + fuzz for the key/path round trip (in the nightly fuzz matrix), a Vault-backed case for /, empty, .. and % components plus a key deleted after the list, and a tracker regression test. New page docs/services/storage/kvs.md.

@AkramBitar AkramBitar added this to the Q3/26 milestone Sep 22, 2026
@AkramBitar AkramBitar self-assigned this Sep 22, 2026
@AkramBitar
AkramBitar marked this pull request as draft September 22, 2026 21:17
@Effi-S
Effi-S self-requested a review September 30, 2026 08:58
@Effi-S
Effi-S marked this pull request as ready for review September 30, 2026 08:58

@Effi-S Effi-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some Findings:

1. Backward-incompatible Vault storage format — Medium

Location: token/services/storage/db/kvs/hashicorp/vault_kvs.go — NormalizeID
Keys with / or % (identity hashes routinely contain /) now map to different Vault
paths, so existing deployments stop finding previously written identity/wallet data. Called
out in a doc WARNING but there is no migration tooling — upgraders must re-register affected
identities manually. Confirm this is acceptable / has a migration story.

2. KVS IdentityExists cannot surface read failures — Medium

Location: token/services/storage/db/kvs/walletdb.go — IdentityExists
The new (bool, error) return only ever carries a key-construction error; kvs.Exists still
swallows Vault read errors (FSC GetExisting), so a transient failure reads as "not bound"
for the KVS backend. The hardening goal (fault ≠ non-membership) is only actually met by the
SQL backend. Documented, but the KVS half of the change is effectively inert.

3. Context threaded but not used for Vault I/O — Low

Location: token/services/storage/db/kvs/hashicorp/vault_kvs.go — read (ignores ctx),
Get, iterator
ctx is now plumbed through but read calls client.Logical().Read(...), not
ReadWithContext. Cancellation/deadlines are not honored. Cosmetic threading; switch to the
*WithContext Vault calls to make it real.

4. StoreIdentity intermediate inconsistency window — Low

Location: token/services/storage/db/kvs/walletdb.go — StoreIdentity
The GetWalletID key [tmsID, roleID, idHash] is written before the IdentityExists key.
A failure between them leaves GetWalletID returning a wallet while IdentityExists reports
false. Retries converge (writes are idempotent), so low impact, but the two reads disagree
in the window. Acceptable if IdentityExists is the sole commit marker.

A composite key maps onto a Vault path by turning each of its components
into a path component, but the components were copied in verbatim. A
component that is empty, or that itself contains a separator, therefore
dropped or added path components, and two distinct composite keys could
resolve to the same secret: CreateCompositeKey("", []string{"1"}) and
CreateCompositeKey("1", nil) both reduce to <mount>/1. The empty case is
reachable by any caller, since an empty attribute passes
CreateCompositeKey's validation. The separator case is worse than latent:
identity hashes are base64-encoded and routinely contain "/", so the key
the iterator reports back splits into more attributes than were stored,
which is exactly what WalletStore.GetConfID matches against. A "." or ".."
component was resolved away by the path.Join the Vault client applies,
letting a component walk out of the configured mount point.

Each component is now percent-escaped, with an empty one encoded as "%"
(url.PathEscape never emits a bare "%") and the dots of "." and ".."
escaped, and deNormalizeID is the exact inverse. Components without "/"
or "%" are unchanged, so paths for well-formed keys stay as they were.
Entries whose components do contain those characters move: that is a
storage-format change for Vault-backed deployments, called out in the new
documentation.

The iterator returned by GetByPartialCompositeID lists keys and reads each
one's value lazily. A key deleted in between hit Get's "no value found"
branch, which returns a nil error without touching the destination, so the
iterator yielded a zero-valued record as if it were stored data -
IdentityConfigurationsIterator had no way to tell. The read now reports
missing separately from failed, happens in HasNext, and a vanished key is
skipped. Vault's KV v1 API has no multi-read endpoint, so the round trip
per key is inherent and is documented on the iterator.

Two more defects in the same function: it returned a nil kvs.Iterator when
the list found nothing, which panics inside
IdentityConfigurationsIterator.HasNext, and it asserted the stored value
was a string without checking, panicking on a malformed secret. Both are
fixed; three assertions in vault_kvs_test.go that encoded the nil-iterator
behaviour now assert an empty iterator.

WalletStore.StoreIdentity writes up to four keys with no atomicity. The
writes are now ordered so the entry IdentityExists reads is written last
and every entry is idempotent, so a failure part-way reports the identity
as not bound and a retry converges; the invariant is documented. The
previous order wrote that entry first, which reported a binding whose
wallet reference was missing.

TrackedKVS recorded the caller's pointer in its Put/Get history, so the
common "one destination reused across calls" idiom made every entry alias
the same object and retroactively show a later call's data. Pointers are
snapshotted into a fresh value of the same type.

fscKVS.Close() called kvs.KVS.Stop(), which only logs a failure to close
the underlying store, and then returned nil unconditionally - a close
failure was unobservable. It now closes the store it created directly and
returns the error. The Vault test helper also leaked the response body of
every successful health poll.

Tests: a table and a fuzz target (wired into nightly-fuzz.yml, which can
now run a target from a nested module) for the composite-key/Vault-path
round trip, a Vault-backed case covering "/" , empty, ".." and "%"
components plus a key deleted after the list, a tracker regression test
for the shared-destination idiom, and close tests for the in-memory
backend. New page docs/services/storage/kvs.md documents the key layout,
the path encoding and its migration note, the iterator semantics and the
partial-write ordering guarantee.

The issue also suggests reordering the wallet and identity keys to put the
identity hash first, to avoid the full-TMS scans in GetConfID and
ConfigurationsByID. That cannot be a pure reordering - GetWalletIDs needs
the existing prefix, so it has to be a secondary index, and existing data
has no index entries, so both lookups would have to keep the scan as a
fallback. It is left as a follow-up rather than bundled here.

Fixes #2041
Signed-off-by: AkramBitar <akram@il.ibm.com>

Signed-off-by: Effi-S <effi.szt@gmail.com>
Adopt the WalletStoreService.IdentityExists signature introduced in #2409,
so a failed lookup is no longer indistinguishable from "the binding does
not exist".

Both implementations are updated. The KVS store previously swallowed the
composite-key error and answered false; it now wraps it. Only key
construction can be reported that way, because this backend's Exists is
FSC's GetExisting, which drops the underlying store error - the same
caveat already documented on GetWalletID.

Registry.ContainsIdentity cannot grow an error return: it backs
driver.Wallet.Contains, which returns a plain bool. It therefore logs the
failure before reporting false, so a storage problem is at least visible.

This duplicates the IdentityExists slice of #2409 verbatim so the two
branches converge without conflict; whichever lands second drops the
hunks as already applied.

Signed-off-by: AkramBitar <akram@il.ibm.com>

Signed-off-by: Effi-S <effi.szt@gmail.com>
@Effi-S
Effi-S force-pushed the 2041-kvs-hardening branch from 97f9bec to 89f201d Compare October 1, 2026 11:41

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

db/kvs: misc medium/low findings (key collisions, N+1 reads, non-atomic writes)

2 participants