You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
server basic auth password check is not constant time #58563
authorized() in the server compares the configured password with ===.
an attacker on the network can measure response times and learn how many leading characters of a guessed password are right, one position at a time.
verifySession() in the same file already uses timingSafeEqual. the password check should too (with a length check first, since timingSafeEqual throws on mismatched lengths).
In the latest release (v2.1.4), the server's Basic-auth check still compares the password with plain === (I checked the compiled binary). It is unchanged on the latest dev (c272a63), while verifySession() in the same file already uses a length check plus timingSafeEqual.
I also timed ServerAuth.authorized() on dev with a long (1 MiB) password and two wrong guesses of the same length. Rejecting a guess that is wrong only in its last character took noticeably longer (~27–31 µs) than one wrong in its first character (~5–6 µs), consistently over three runs. So the rejection time does depend on how much of the guess is right. The script below does this measurement.
I didn't measure this over a network, where the difference for a normal-length password would be much smaller. Your PR #58337 takes the approach you describe, and maintainers can review it there.
Reproduction script (timing.ts)
// Shows that ServerAuth.authorized() (packages/server/src/auth.ts) rejects a wrong// password faster when it differs early than when it shares a long prefix with the// real one, i.e. the Basic-auth password check is not constant time (`===`).// Run from a checkout of anomalyco/opencode after `bun install`:// bun timing.ts /path/to/opencodeconstrepo=process.argv[2]??"/workspace/repo"const{ ServerAuth }=awaitimport(`${repo}/packages/server/src/auth.ts`)const{ Option, Redacted }=awaitimport(Bun.resolveSync("effect",`${repo}/packages/server/src`))constN=1<<20// long password so per-character work is measurable in-processconstflat=(s: string)=>s.split("").join("")// fresh, flat heap stringconstconfig={username: "opencode",password: Option.some(flat("a".repeat(N)))}constK=40// fresh copies per call so the JIT cannot hoist or cache the comparisonconstfirstWrong=Array.from({length: K},()=>flat("b"+"a".repeat(N-1)))constlastWrong=Array.from({length: K},()=>flat("a".repeat(N-1)+"b"))functionmedian(guesses: string[]){consttimes=guesses.map((g)=>{constc={username: "opencode",password: Redacted.make(g)}constt=Bun.nanoseconds()if(ServerAuth.authorized(c,config))thrownewError("accepted wrong password")returnBun.nanoseconds()-t})returntimes.sort((x,y)=>x-y)[K>>1]/1000}// verifySession() runs for every rejected guess too; time it alone as a baselinefunctionmedianSession(guesses: string[]){consttimes=guesses.map((g)=>{constt=Bun.nanoseconds()ServerAuth.verifySession(g,config)returnBun.nanoseconds()-t})returntimes.sort((x,y)=>x-y)[K>>1]/1000}median(firstWrong.slice());median(lastWrong.slice())// warm up (strings get re-copied below)for(constarrof[firstWrong,lastWrong])for(leti=0;i<K;i++)arr[i]=flat(arr[i])consta=median(firstWrong),b=median(lastWrong)constsa=medianSession(firstWrong),sb=medianSession(lastWrong)console.log(`authorized(), mismatch at char 0: ${a.toFixed(1)} µs`)console.log(`authorized(), mismatch at last char: ${b.toFixed(1)} µs`)console.log(`verifySession() alone (baseline): ${sa.toFixed(1)} / ${sb.toFixed(1)} µs`)console.log(`password-compare cost: ${(a-sa).toFixed(1)} µs vs ${(b-sb).toFixed(1)} µs`)console.log(b-sb>3*Math.max(a-sa,1) ? "=> rejection time grows with the matching prefix: NOT constant time" : "=> no prefix-dependent timing seen")
Activity
opencode-agent commentedon Oct 24, 2026
Thanks for the report. I could reproduce this.
===(I checked the compiled binary). It is unchanged on the latestdev(c272a63), whileverifySession()in the same file already uses a length check plustimingSafeEqual.ServerAuth.authorized()ondevwith a long (1 MiB) password and two wrong guesses of the same length. Rejecting a guess that is wrong only in its last character took noticeably longer (~27–31 µs) than one wrong in its first character (~5–6 µs), consistently over three runs. So the rejection time does depend on how much of the guess is right. The script below does this measurement.I didn't measure this over a network, where the difference for a normal-length password would be much smaller. Your PR #58337 takes the approach you describe, and maintainers can review it there.
Reproduction script (timing.ts)
Run it:
bun timing.ts