Skip to content

lore-credential: Stop the keychain thrashing in the credential store - #170

Closed
mjansson wants to merge 1 commit into
EpicGames:mainfrom
mjansson:mjansson/keyring-fixes
Closed

lore-credential: Stop the keychain thrashing in the credential store#170
mjansson wants to merge 1 commit into
EpicGames:mainfrom
mjansson:mjansson/keyring-fixes

Conversation

@mjansson

Copy link
Copy Markdown
Collaborator

Motivation

encrypt_token persisted a nonce counter into the OS keyring before every seal. The key never changed — but counter and key shared one blob, so every stored token rewrote the keyring entry, and tokens are stored on each authz exchange, not just at login.

On macOS that entry is a SecKeychain item whose ACL is a list of trusted binaries. Every application linking lore prompted for write access, and every rebuild invalidated the grant and prompted again.

Separately, get_secret_from_store treated any keyring error as "no key", so a single denied prompt regenerated the key and wiped the token store — for every lore process on the machine, which then provoked the next prompt.

The fix

Random nonces. Each seal draws a fresh 96-bit nonce and carries it in the blob. No counter, so nothing to persist: the key is written once at init and only read from then on. The keyring writes are simply gone.

**An unreadable store is not an empty one. **Only NoEntry counts as "no key"; every other keyring failure propagates. A denied or cancelled prompt can no longer rotate the key out from under anyone.

Its own files. The store moves to tokenstore.toml and the key to a tokenstore_encryption_key target (which also renames the on-disk fallback) to avoid interference between old and new clients.

Why nothing is migrated

Sharing state is what caused the damage. An old client that can't read a token re-authenticates and rewrites the entry in its own format; one that can't read the key regenerates it and clears the store for everybody. Copying the old credentials forward would have kept both problems alive, and duplicating one-time-use refresh tokens across two clients would risk tripping rotation reuse-detection and logging out both.

So the new store shares nothing and reads nothing older. Old clients keep using tokens.toml and their own keyring slot, untouched — the two generations can run side by side on one machine and neither can reset the other. Cost: one lore login on first use.

Security

Removing the counter also removes a real hazard: the old design had no compare-and-swap on the keyring, so two processes could read the same counter and seal with the same nonce — catastrophic under AES-GCM. Random 96-bit nonces need no shared state, and SingleNonceSequence structurally prevents a key from sealing twice. With one wire format left, the marker, the dual-path decode, the padding truncation, and the legacy-key adoption all go away (−263 lines). generate_encryption_key now propagates RNG failure rather than returning a zeroed key.

An adversarial security review of the final diff found no reportable issues; nonce reuse, format confusion, key lifecycle, lock ordering, and secret exposure in logs were each examined and ruled out.

Testing

11 new: seal/open round trip, nonce freshness and prefix layout, foreign-key rejection, malformed and short-blob handling) and 13 integration tests pass.

`encrypt_token` persisted a nonce counter into the OS keyring before every
seal. The key never changed, but counter and key shared one blob, so every
stored token rewrote the keyring entry — and tokens are stored on each authz
exchange, not just at login.

On macOS that entry is a legacy `SecKeychain` item whose ACL is a list of
trusted binaries, so every application linking lore prompted for write access
and every rebuild invalidated the grant and prompted again. Separately,
`get_secret_from_store` treated any keyring error as "no key", so one denied
prompt regenerated the key and called `reset_tokens()` for every lore process
on the machine, which then provoked the next prompt.

Draw a random 96-bit nonce per seal and carry it in the sealed blob instead.
With no counter there is nothing to persist: the key is written once at init
and only read from then on, and the keyring writes are gone. Narrow the
absence check to `keyring::Error::NoEntry` so an unreadable store is an error
rather than an invitation to replace the key.

Give the store its own artifacts — `tokenstore.toml` and a
`tokenstore_encryption_key` target, the latter also naming the on-disk key
fallback — under an `org.lore` service, and migrate nothing. Sharing state is
what caused the damage: a client that cannot read a token re-authenticates and
rewrites the entry in its own format, and one that cannot read the key clears
the store for everyone. Older versions keep their own files untouched, so both
can run on one machine without resetting each other. The cost is one login on
first use.

Removing the counter also removes a real hazard: writing it back was not a
compare-and-swap, so two processes could read the same value and seal with the
same nonce. `SingleNonceSequence` now structurally prevents a key from sealing
twice. With one wire format left, the format marker, the dual-path decode, the
padding truncation and the legacy-key adoption all go.

Also propagate the RNG failure in `generate_encryption_key`, which starts from
a zeroed buffer of the right length and so silently returned an all-zero key
that every later check accepts; and fix a latent bug where the keyring entry
cache was a single `OnceLock` that returned whichever target was requested
first for every subsequent target.

Signed-off-by: Mattias Jansson <mjansson@gmail.com>
@mjansson
mjansson force-pushed the mjansson/keyring-fixes branch from 2f11b00 to 991f9b6 Compare August 16, 2026 12:40
@mjansson mjansson added the ready-to-import Approved by Epic staff for import into Lore label Aug 16, 2026
@epic-lore-bot

epic-lore-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

Imported as Lore CR-366.

@epic-lore-bot epic-lore-bot Bot added imported Imported into Lore for internal review and removed ready-to-import Approved by Epic staff for import into Lore labels Aug 16, 2026
epic-lore-bot Bot pushed a commit that referenced this pull request Aug 17, 2026
## Motivation
`encrypt_token` persisted a nonce counter into the OS keyring before *every* seal. The key never changed — but counter and key shared one blob, so every stored token rewrote the keyring entry, and tokens are stored on each authz exchange, not just at login.

On macOS that entry is a `SecKeychain` item whose ACL is a list of trusted binaries. Every application linking lore prompted for write access, and every rebuild invalidated the grant and prompted again.

Separately, `get_secret_from_store` treated *any* keyring error as "no key", so a single denied prompt regenerated the key and wiped the token store — for every lore process on the machine, which then provoked the next prompt.

### The fix
**Random nonces.** Each seal draws a fresh 96-bit nonce and carries it in the blob. No counter, so nothing to persist: the key is written once at init and only read from then on. The keyring writes are simply gone.

**An unreadable store is not an empty one. **Only `NoEntry` counts as "no key"; every other keyring failure propagates. A denied or cancelled prompt can no longer rotate the key out from under anyone.

**Its own files.** The store moves to `tokenstore.toml` and the key to a `tokenstore_encryption_key` target (which also renames the on-disk fallback) to avoid interference between old and new clients.

### Why nothing is migrated
Sharing state is what caused the damage. An old client that can't read a token re-authenticates and rewrites the entry in its own format; one that can't read the key regenerates it and clears the store for everybody. Copying the old credentials forward would have kept both problems alive, and duplicating one-time-use refresh tokens across two clients would risk tripping rotation reuse-detection and logging out both.

So the new store shares nothing and reads nothing older. Old clients keep using `tokens.toml` and their own keyring slot, untouched — the two generations can run side by side on one machine and neither can reset the other. **Cost: one `lore login` on first use.**

### Security
Removing the counter also removes a real hazard: the old design had no compare-and-swap on the keyring, so two processes could read the same counter and seal with the same nonce — catastrophic under AES-GCM. Random 96-bit nonces need no shared state, and `SingleNonceSequence` structurally prevents a key from sealing twice. With one wire format left, the marker, the dual-path decode, the padding truncation, and the legacy-key adoption all go away (−263 lines). `generate_encryption_key` now propagates RNG failure rather than returning a zeroed key.

An adversarial security review of the final diff found no reportable issues; nonce reuse, format confusion, key lifecycle, lock ordering, and secret exposure in logs were each examined and ruled out.

### Testing
11 new: seal/open round trip, nonce freshness and prefix layout, foreign-key rejection, malformed and short-blob handling) and 13 integration tests pass.

```
Imported-PR: #170
Imported-From: 991f9b6
Imported-Base: 935fccb
Imported-Merge: a327586
Imported-Author: Mattias Jansson (mjansson)
Signed-off-by: Mattias Jansson <mjansson@gmail.com>
GH-URL: #170
```

Lore-RevId: 586
Lore-Signature: 39954b2a04cb602a4e34dc6a4b523967c1e103ede172610fa91057af149987a9
@epic-lore-bot

epic-lore-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

Closed by mirrored commit 0ba8258.

@epic-lore-bot epic-lore-bot Bot closed this Aug 17, 2026
@epic-lore-bot epic-lore-bot Bot added the merged Merged into Lore codebase label Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

imported Imported into Lore for internal review merged Merged into Lore codebase

Development

Successfully merging this pull request may close these issues.

1 participant