fix(identity): mint bot passwords from 32 random bytes, not the clock (TASK-163) - #1941
Merged
Merged
Conversation
… (TASK-163)
`getOrCreateAgentUser` set `password: `agent-password-${Date.now()}`` — a
millisecond timestamp against an identity whose username and botMetadata are
both public. Nothing reads the value and no login path accepts a bot, so the
shape bought nothing; it would have become a reconstructable credential the
first time a login path was added without the `isBot` filter that five call
sites currently carry.
One line at the mint: `crypto.randomBytes(32).toString('hex')`. No behaviour
change and no migration — existing bot rows keep their timestamp passwords
until the operator runs the separate dry-run-first rotation script.
The witnesses observe the two places the property is visible: the stored hash,
via the attack (freeze the clock, reconstruct the guess, it must not
authenticate), and the plaintext as it reaches `bcrypt.hash` (shape, and that
two seats minted in the same millisecond differ). The row's two original
witnesses could not fail: bcrypt salts per call, so identical plaintexts still
store different hashes, and the plaintext is gone once the pre-save hook runs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cut from
97f1aa66(main, after #1938/#1939 merged). One line of source, one new suite. Backend only — no UI, no versioned package, no migration.The defect
getOrCreateAgentUser(backend/services/agentIdentityService.ts, the single mint site) set:A millisecond timestamp, against an identity whose username and
botMetadataare both public. Measured on production (Vera, read-only): 577 of 577 bot users carry a non-empty password, all minted this way.It is not exploitable today, and the reason matters: not because the credential is strong, but because five call sites remember to filter
isBot—authController.ts:760and the two recovery paths (:625,:668), plus three refusals by name inoauthController.ts(:272,:276,:365). That is an invariant held by repetition, which is the shape that decays: a sixth login path (SSO, magic link, device code) that forgets the filter converts 577 guessable credentials into 577 logins, and nothing at the mint says the password is load-bearing.So the fix is at the mint, not at the guards:
No behaviour change (nothing reads the value), no migration (existing rows keep what they have; rotating them is a separate dry-run-first script on the operator's word, per the row).
The witnesses, and why the row's originals were replaced
The row's two proposed arms could not fail, so neither is here:
models/User.ts:421hashes withbcrypt.hash(password, 10), and bcrypt salts per call, so identical plaintexts still store different hashes. It passes before and after the fix, and the revert mutation stays green.Date.now()" is unobservable after the pre-save hook: there is no plaintext left on the row to inspect.Three arms, each observing a place the property is actually visible:
the creation-time guess does not authenticate the minted seatcomparePassword('agent-password-<frozen ms>')must be false. Control: the row carries a real bcrypt hash (/^\$2[aby]\$/), so thefalseis the mint's property and not an empty fieldthe plaintext handed to the hash is random hex, not a low-entropy templatebcrypt.hash, via a spy — and it is deliberately blind toDate.now(), so a future edit that swaps one guessable template for another is still caught. Control first:expect(hashSpy).toHaveBeenCalled()two seats minted in the same millisecond do not share a plaintextMutation ledger
Baseline and restore 3/3 green; each mutation reverted before the next.
agent-password-${Date.now()}backrandomBytes(8)(right shape, too little of it)sha256(String(Date.now()))'agent-password'Two disclosures from that table, rather than tidy summaries:
not.toMatchpass.Verification
backend/services/agentIdentityService.tscarries 6 eslint warnings atHEADand 6 atHEAD~1— the same six lines (11/19/27/467/655/698), none on a line this PR touches: 0 diagnostics added.import/no-unresolved+import/extensionsdiagnostics. That is the ambient class in this repo's un-gated.jstest corpus: the sibling suitedecisionCardReply.bridges.test.jscarries 83 diagnostics including the same rules. (The first neighbour I measured,installableInstallationService.test.js, reported only 1 — aParsing error: Identifier directly after numberthat stops eslint before the import rules run, so that comparison was void and is not being used.)docs/*no change needed; no new ADR, no new row. Line-number note: the row citesagentIdentityService.ts:503for the mint; the password line is at:511on this head after the import and the comment. Same single site either way.