Skip to content

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

Open
AkramBitar wants to merge 1 commit into
mainfrom
2041-kvs-hardening
Open

AkramBitar wants to merge 1 commit 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)

#2409 has landed, so the WalletStoreService.IdentityExists signature is already on main and what remains here is only the KVS side of it: a failed lookup is no longer indistinguishable from "the binding does not exist". The interface, sql/common/wallet.go, the mock and the test callers all came from #2409 and are not in this diff.

The entry is read with kvs.Get rather than probed with kvs.Exists, the way GetWalletID already does. FSC implements Exists as len(GetExisting(...)) > 0 and GetExisting drops the underlying store error, so a transient failure would masquerade as "not bound" — the confusion the error return exists to remove. Get is the only method on the KVS surface that propagates the store error, so absence is classified from the error instead: an authoritative "not found" is a miss, everything else reaches the caller.

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

Second commit: every Vault absence shape is a miss

From review round 4. read() reported a missing secret as absent, but answered a secret with no data — or a data field that was missing, empty or not a map — with an error, which aborts the entire prefix scan behind GetConfID and GetWalletIDs. Exists already classified all three shapes as absent, so the two disagreed on what exists within the same file.

It is reachable without a malformed secret: KV v1 has one mount per writer-set, so anything else writing under it (an operator running vault kv put, another application sharing the mount) leaves an entry with no data map of ours that any scan over that mount reaches. One of those under the prefix hid every real entry below it.

read() now mirrors Exists and the iterator's existing skip path handles the result. A secret that does carry a data map is this KVS' own entry, so a value inside it that is missing, not a string or not decodable stays an error — that entry is corrupt, not absent.

This removes the only place in the tree that produced state of id [...] does not exist, the message WalletStore's anchored not-found match looks for on this backend. The head stays listed because hashicorp is a module of its own, versioned independently of the root module, so a deployment can still pair this store with a release that returns it.

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, a tracker regression test, and dbtest's wallet suite wired to the in-memory and Vault-backed KVS (it previously ran against the SQL drivers only, which is why the GetConfID and GetWalletIDs defects went unnoticed). From round 4: testForeignSecretIsAbsentNotAnError, and TestIsNotFoundErrMatchesLiveBackend, which classifies the error the in-tree KVS really returns so a rewording upstream fails in CI rather than in a deployment. 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.

@AkramBitar

AkramBitar commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @Effi-S. All four addressed; squashed into 5ff00ea.

1. Vault format break — acknowledged, no migration tool. Intentional; doc note expanded. The old mapping escaped nothing, so it wasn't injective: <mount>/a/b/c doesn't say whether it was written under ["a","b","c"] or ["a/b","c"]. Keys can't be read back off the paths, only reconstructed from the known layout — hence re-register rather than a mechanical rewrite. The backend is a separate module with no in-tree consumer, so it's opt-in. Happy to add a migration command as a follow-up if you think deployments need one.

2. IdentityExists inert — fixed. You were right. It now reads the entry with kvs.Get instead of probing with kvs.Exists, the way GetWalletID already does: "does not exist" is an authoritative miss, every other error propagates. One wrinkle — the Vault backend reports a missing id as a nil error with the destination untouched, so an empty value is also a miss. That's safe because GetWalletID already reserves "" for "no binding". Added a round-trip test and a fault-injection test.

3. Context unused — fixed. read, Exists, Put, Delete and the iterator's list now use the *WithContext calls, so cancellation and deadlines are real. Containerised Vault tests pass. Also bumped Exists's swallowed read error from Debugf to Errorf, since its interface can't return one.

4. StoreIdentity window — no change, by design. IdentityExists is the sole commit marker, as you suspected. The ordering is deliberate and documented, every write is idempotent so retries converge, and Registry.ContainsIdentity already treats a failed lookup as false.


Out of scope, found while fixing #2: IdentityStore.ConfigurationExists (identitydb.go:208) has the identical inert pattern — pre-existing, so I didn't widen the diff. Happy to open an issue. The Vault test suite also leaves its containers behind.

@AkramBitar
AkramBitar requested a review from Effi-S October 5, 2026 11:38
Comment thread token/services/storage/db/kvs/hashicorp/vault_kvs.go
Comment thread token/services/storage/db/kvs/hashicorp/vault_kvs.go
Comment thread token/services/storage/db/kvs/hashicorp/vault_kvs.go
AkramBitar added a commit that referenced this pull request Oct 5, 2026
Addresses the review of #2408.

GetByPartialCompositeID on the Vault backend listed one level. Vault's list
is single-level and reports a path that has children as a directory marker
holding no state of its own, so a caller whose entries sit deeper than one
component scanned nothing: WalletStore.GetConfID listed walletDB/<tms>, got
back the <role>/ markers, skipped every one of them as not found and reported
each bound identity as unbound. Walk the subtree instead, iteratively so a
deep tree cannot exhaust the stack, skipping a directory that disappears
mid-walk and returning a leaf-and-directory path once. The result is sorted:
a depth-first walk's order depends on the tree shape, and the SQL backends
this store shares a test suite with scan in key order.

Walking the subtree exposed a second defect, on every backend rather than
just Vault. WalletStore.GetWalletIDs scans [tmsID, roleID] and did not filter
what came back, so the per-wallet "configid" and "meta" entries below it were
reported as wallet ids:

  GetWalletIDs -> ["alice_wallet" "CONF-ID-XYZ" "bWV0YQ=="]

Filter by attribute count, the way GetConfID already filters for its own
entry. Neither defect was caught because dbtest's wallet suite was wired to
the SQL drivers only; export WalletCases and run it against both KVS
backends. TWalletConfigurationLink is skipped there, as this store has no
equivalent of the Wallets.conf_id foreign key it asserts.

Also memoize vaultIterator's lookahead, so that calling HasNext more than
once before Next neither repeats the Vault read nor drops the key it
advanced to, and document the exported methods of the Vault KVS.

Signed-off-by: AkramBitar <akram@il.ibm.com>
Comment thread token/services/storage/db/kvs/walletdb.go Outdated
Comment thread token/services/storage/db/kvs/hashicorp/vault_kvs.go
@AkramBitar
AkramBitar requested a review from Effi-S October 6, 2026 13:31

@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.

@AkramBitar Looks good. Only minor findings

Comment thread token/services/storage/db/kvs/hashicorp/vault_kvs.go
Comment thread token/services/storage/db/kvs/walletdb.go
Comment thread token/services/storage/db/kvs/walletdb.go
AkramBitar added a commit that referenced this pull request Oct 7, 2026
Review round 4 on #2408.

The Vault KVS' read() reported a missing secret as absent but answered a
secret with no data, or a "data" field that was missing, empty or not a
map, with an error. Exists already classified all three as absent, so the
two disagreed on what exists within the same file, and the error aborted
the whole prefix scan behind GetConfID and GetWalletIDs. KV v1 has one
mount per writer-set, so a secret written under it by an operator or
another application carries no "data" map of ours and is reachable by any
scan over that mount: one foreign entry under the prefix hid every real
entry below it.

read() now mirrors Exists and reports all three shapes as absent, which
the iterator already skips. A secret that does carry a "data" map is this
KVS' own entry, so a "value" inside it that is missing, not a string or
not decodable stays an error - that entry is corrupt, not absent.

This removes the only place in the tree that produced
"state of id [...] does not exist", the message WalletStore's
notFoundHeads matches for this backend. The head stays listed because
hashicorp is a module of its own, versioned independently of the root
module, so a deployment can still pair this store with a release that
returns it; walletdb.go now says that rather than claiming the in-tree
backend emits it.

Also from the same review, both documentation:

- The leading space in notFoundSuffix is recorded as deliberate: it
  anchors the phrase on a word boundary, so an error whose final word
  merely ends in "does not exist" cannot match.
- The anchored match's fragility is stated in the direction raised. It is
  one-sided on purpose: a reworded absence error propagates as a hard
  error, which makes the role Registry abort a wallet creation loudly and
  recoverably, where a widened match would answer ("", nil) for an
  unreadable store and create a duplicate wallet, which is #2063. The
  match is never to be loosened, only replaced by a sentinel.

Tests: testForeignSecretIsAbsentNotAnError writes a secret straight
through the Vault API plus one with an empty "data" and asserts Get
misses, Exists is false and the scan still yields the real entry; it
fails without the fix. TestIsNotFoundErrMatchesLiveBackend classifies the
error the in-tree KVS really returns, so a rewording upstream fails in CI
instead of turning every miss into an error in production.

Signed-off-by: AkramBitar <akram@il.ibm.com>
@AkramBitar
AkramBitar requested a review from Effi-S October 7, 2026 11:52
The KVS-backed identity and wallet stores reported a storage fault as
absence, so a transient backend failure was indistinguishable from "this
identity is not registered" and could let a caller create a duplicate
wallet.

- WalletStore.IdentityExists returns (bool, error), and the role Registry
  and the mocks follow. An error means the lookup itself failed and the
  answer is unknown; it must not be read as "the binding does not exist".
  The entry is read with kvs.Get rather than probed with kvs.Exists,
  because FSC implements Exists over GetExisting, which drops the
  underlying store error: only a "not found" error is an authoritative
  miss, every other error reaches the caller. GetWalletID classifies
  absence the same way. The driver interface and the SQL backend carry
  this signature already, from #2409.

  FSC has no absence sentinel yet, so that classification has to match on
  the error message, and the match is anchored at both ends rather than
  searching for the "does not exist" substring. A store failure can carry
  that very phrase -- a missing or not-yet-migrated table makes Postgres
  answer `relation "kvs" does not exist` -- and reading it as a miss is
  the duplicate-wallet mode this change exists to close. A backend returns
  its absence error unwrapped, so a miss starts with the sentinel, while a
  failure always arrives wrapped in "failed retrieving state [...]" and
  can never match it, whatever its cause says.

  The anchoring ties the match to the exact wording of the heads it knows,
  which makes it fragile in one direction only, deliberately. A reworded
  absence error has its miss propagated as a hard error, so GetWalletID
  fails for an unbound identity and the Registry aborts the wallet
  creation: loud and recoverable. Widening the match buys that back at the
  price of the opposite failure, ("", nil) for an unreadable store, which
  is the duplicate wallet again and is neither. The match is therefore
  never to be loosened, only replaced once a typed sentinel lands.

- StoreIdentity writes the entry IdentityExists reads last, and every
  write is idempotent, so a sequence that fails part-way leaves the
  identity reported as not bound and a retry converges on the complete
  binding. A partially applied sequence is never reported as a complete
  one.

- The hashicorp vault KVS percent-escapes each composite-key component
  before it becomes a path component, which makes the key-to-path mapping
  injective: distinct keys no longer collide, a listed path decodes back
  to the composite key it was stored under, and a "." or ".." component
  cannot walk out of the configured mount point. This changes the stored
  path for components containing "/" or "%" -- see the warning in the
  docs.

- Vault I/O goes through the client's *WithContext calls, so the context
  that is threaded through actually carries cancellation and deadlines.
  Exists has no way to report a read failure through its interface, so
  that failure is logged as an error rather than at debug level.

- The iterator reads each key as it advances and skips a key deleted
  between the list and its read instead of yielding a zero-valued state;
  a prefix with nothing under it yields an empty iterator, never nil. Its
  lookahead is memoized, so calling HasNext more than once before Next
  neither repeats the Vault read nor drops the key it advanced to.

- Every shape the Vault backend has for "nothing of ours is stored here"
  is one answer: no secret at the path, a secret carrying no data, and a
  secret whose "data" field is missing, empty or not a map. Only the first
  was reported as absent; the other two returned an error, which aborts
  the whole prefix scan behind GetConfID and GetWalletIDs. Exists already
  classified all three as absent, so it and the read path disagreed on
  what exists. KV v1 has one mount per writer-set, so an entry written
  under it by an operator or another application carries no "data" map of
  ours and is reachable by any scan over that mount -- one of those under
  the prefix hid every real entry below it. The read path now mirrors
  Exists and the iterator's skip handles the result. A secret that does
  carry a "data" map is this KVS' own entry, so a "value" inside it that
  is missing, not a string or not decodable stays an error: that entry is
  corrupt, not absent. This leaves the nil-error-with-untouched-state
  shape as the backend's only way of reporting a miss, which is why
  IdentityExists treats an empty wallet id as "not bound" too.

GetByPartialCompositeID on the Vault backend also listed one level only.
Vault's list is single-level and reports a path that has children as a
directory marker holding no state of its own, so a caller whose entries
sit deeper than one component scanned nothing: WalletStore.GetConfID
listed walletDB/<tms>, got back the <role>/ markers, skipped every one of
them as not found and reported each bound identity as unbound. It walks
the subtree instead, iteratively so a deep tree cannot exhaust the stack,
skipping a directory that disappears mid-walk and returning a
leaf-and-directory path once.

The scan is sorted, because a depth-first walk's order depends on the tree
shape and the SQL backends this store shares a test suite with scan in key
order. The sort is applied to the composite keys the listed paths decode
to, not to the paths themselves: a path joins components with "/" where a
composite key separates them with "\x00", and components that need it are
percent-escaped, so the two forms do not order alike -- "\x00" and the "%"
of an escape both sort below "-" while "/" sorts above it. Sorting the
paths left a Vault scan ordered differently from the SQL backends, which
is reachable with real keys, since identity hashes are base64 and
routinely contain "/".

Walking the subtree exposed a second defect, on every backend rather than
just Vault. WalletStore.GetWalletIDs scans [tmsID, roleID] and did not
filter what came back, so the per-wallet "configid" and "meta" entries
below it were reported as wallet ids:

  GetWalletIDs -> ["alice_wallet" "CONF-ID-XYZ" "bWV0YQ=="]

Filter by attribute count, the way GetConfID already filters for its own
entry. Neither defect was caught because dbtest's wallet suite was wired
to the SQL drivers only; export WalletCases and run it against both KVS
backends. TWalletConfigurationLink is skipped there, as this store has no
equivalent of the Wallets.conf_id foreign key it asserts.

Adds unit tests for the path encoding, the not-found contracts, the
fault-injection paths, the subtree walk, scan order, HasNext idempotency
and a foreign secret under the mount, a FuzzNormalizeIDRoundTrip target
wired into nightly-fuzz.yml, docs under docs/services/storage/kvs.md, and
Godoc for the exported methods of the Vault KVS. The not-found
classification is additionally pinned against the error the in-tree KVS
really returns, so a rewording upstream fails in CI rather than turning
every miss into an error in a deployment.

Fixes #2041

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

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