Repository navigation
fix(auth): carry a client credential only for the client its store stamp names - #2739
Conversation
…amp names A repeated secrets.json hand-off could pair a carried client secret with the wrong client (#2734). The carry trusted the owner the namespace ledger recorded per location, but a value reaches the keychain without the ledger seeing it: the hand-off keeps an existing keychain value and copies a file's otherwise, so after a re-registration between two hand-offs the file record named client B while the keychain held A's secret. The same gap made the keychain's cleanup index read as proof of a keychain write, and let several secrets.json files' records override one another in insertion order. Each DCR client credential now carries an owner stamp beside it in the store -- the client_id it was written for plus a digest of the value. The hand-off copies or keeps the stamp exactly as it does the value, so it names the client of the value actually there, and the digest refuses a stamp separated from its value. Carries (the pending read and the purge's copy) verify the stamp against the client the state file holds now; the ledger's owners only name which fields to look in. Adoption stamps the legacy values it moves, so they stay carriable. Closes #2734 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]>
There was a problem hiding this comment.
🟡 Changes recommended
A failed stamp write leaves a partial carry that retries skip, risking credential loss during later re-adoption.
1 open issue
What changed in this PR
Fixes OAuth credential ownership across secret-store hand-offs by storing value-bound owner stamps beside credentials.
Changes:
- Validates owner stamps before carrying credentials between namespaces.
- Restamps adopted credentials and replaces ledger-owner inference with candidate-field lookup.
- Adds regression coverage and updates storage documentation.
| File | Description |
|---|---|
| docs/secret-storage.md | Documents stamp-based ownership and carry behavior. |
| core/auth/node/oauth-secrets.ts | Adds stamp creation, validation, and restamping. |
| core/auth/node/oauth-persist-file.ts | Applies stamps during adoption and pending reads. |
| core/auth/node/oauth-namespace-ledger.ts | Validates and copies stamps during carries. |
| clients/web/src/test/integration/auth/node/oauth-secrets-namespace.test.ts | Covers repeated hand-offs and legacy adoption. |
| clients/web/src/test/core/auth/oauth-secrets.test.ts | Tests stamp helpers and credential splitting. |
| clients/web/src/test/core/auth/oauth-namespace-ledger.test.ts | Tests stamp-based carry decisions. |
Review effort: Balanced
A failed stamp write after the value landed left an unstamped value the retry skipped as already held, letting the source be purged with no stamp left to vouch for the carried copy (#2734 review). Writing the stamp first makes either failure leave the field vacant and retryable. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]>
|
Copilot round 1 (1 comment, no suppressed comments)
|
|
Copilot review loop closed. Round 2 was clean — no inline comments, no suppressed comments, and the round-1 finding marked resolved — so the loop stops on its first clean round. |
CodeQL flagged the stamp's plain SHA-256 of the client secret as an insufficient password hash (js/insufficient-password-hash, alert #82). The digest sat in the same store as the secret, so it revealed nothing that store does not, but the shape is still a fast unkeyed hash of a secret. The stamp now carries an HMAC over the client_id keyed by the credential: only a writer holding the value can produce it, it matches only that value, and it also refuses a stamp whose client_id was swapped. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]>
|
CodeQL #82 flagged the first version of the owner stamp, a plain SHA-256 of the client secret. 15de439 replaced it with an HMAC-SHA256 over the Why the rule's threat doesn't apply here:
So the HMAC stays, and #83 should be dismissed as false positive with a pointer to this comment. |
|
Copilot review loop closed (again). Round 3 reviewed the HMAC change (15de439) and was clean: no inline comments and no suppressed comments. Its headline ("warrants final human validation") names no specific defect, so it is a pointer for the maintainer review rather than a finding. CodeQL #82 is fixed and #83 was dismissed as a false positive (see the comment above). |

Closes #2734
Problem
#2731 carries a client credential from a superseded namespace only where the state file still holds the client the ledger recorded as its owner. But a value reaches the keychain without the ledger seeing it. The file-to-keychain hand-off keeps an existing keychain value and copies a file's value otherwise, so per-location ledger records can't say which value is there. All three cases on the issue come from that gap:
foldOwnersdiscards the file's valid owner, and the only secret is purged without being carried.secrets.jsonfiles (first comment):effectiveOwnersmergesfile:records in insertion order and picks the wrong file's owner.Fix
The hand-off now carries each value's owner along with the value, as the issue comment proposes. Instead of teaching the hand-off about state files and ledgers (it knows neither), the owner is stored next to the value in the secret store:
client_secret/ registration token gets an owner stamp in a separate…#ownerfield. The stamp holds theclient_idit was written for and an HMAC-SHA256 over thatclient_idkeyed by the value itself, so the secret is never the hashed input (CodeQLjs/insufficient-password-hash) (clientCredentialStampField,clientCredentialStamp,stampedOwnerinoauth-secrets.ts).splitServerOAuthStateemits it, andserverSecretFieldslists it, so the save path's set, delete and rollback handling covers stamps unchanged. Pre-registered clients get no stamp, for the reasonclientCredentialOwnersalready gives.client_idwas swapped: a kept value next to a copied stamp, or a value an older Inspector rewrote without its stamp.absorbFileSecretsIntoKeyringitself is unchanged.readEntrySecretsandcarryOwed. A field is carried only when its stamp vouches for the value and names the client the state file holds now, and the stamp is copied with it. The ledger'sownersnow only say which fields to look in (candidateFields, a union over the covered records).foldOwners/effectiveOwnersare gone.restampClientCredentials) the values it moves. Legacy-id values predate stamps, and a fallback's stamp may belong to a value read from elsewhere. A stamp that vouches for its value is kept; otherwise the file's own pairing is stamped, the same assumption fix(auth): carry a superseded namespace's client secret across a backend switch #2731 made at adoption. This keeps adopted legacy secrets carriable.The ledger format is unchanged. #2731's owner records are in no release yet, so nothing needs migrating.
A value with no stamp that vouches for it is purged as before, not guessed at: either it was written before this change or it no longer sits beside its own stamp. Such a value had no carry before #2731 either, and re-authorizing recovers.
Tests
oauth-namespace-ledger.test.ts: the three cases from the issue at ledger level, plus stamp rejection (no stamp, malformed, not an object, no client, another value's stamp). The A repeated secrets.json hand-off can pair a carried client secret with the wrong client #2734 cases fail on the old owner logic.oauth-secrets.test.ts: stamps from the split (DCR yes, pre-registered no, none without aclient_id),stampedOwnerrejections,restampClientCredentials.oauth-secrets-namespace.test.ts(integration): the issue's repro end to end through two realabsorbFileSecretsIntoKeyringcalls (fails onv2/main, passes here), the matching positive case with no re-registration, and a legacy secret adopted, stripped and re-adopted elsewhere that is still carried.docs/secret-storage.mdnow describes the stamp in place of the ledger-recorded owner.npm run formatandnpm run local:gatepass. No UI change, so no screenshots.🤖 Generated with Claude Code