Skip to content

server basic auth password check is not constant time #58563

Open

Description

@benjaminkincses

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

Activity

  1. opencode-agent commented on Oct 24, 2026

    @opencode-agent
    Contributor

    Thanks for the report. I could reproduce this.

    • 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/opencode
    const repo = process.argv[2] ?? "/workspace/repo"
    const { ServerAuth } = await import(`${repo}/packages/server/src/auth.ts`)
    const { Option, Redacted } = await import(Bun.resolveSync("effect", `${repo}/packages/server/src`))
    
    const N = 1 << 20 // long password so per-character work is measurable in-process
    const flat = (s: string) => s.split("").join("") // fresh, flat heap string
    const config = { username: "opencode", password: Option.some(flat("a".repeat(N))) }
    const K = 40
    // fresh copies per call so the JIT cannot hoist or cache the comparison
    const firstWrong = Array.from({ length: K }, () => flat("b" + "a".repeat(N - 1)))
    const lastWrong = Array.from({ length: K }, () => flat("a".repeat(N - 1) + "b"))
    
    function median(guesses: string[]) {
      const times = guesses.map((g) => {
        const c = { username: "opencode", password: Redacted.make(g) }
        const t = Bun.nanoseconds()
        if (ServerAuth.authorized(c, config)) throw new Error("accepted wrong password")
        return Bun.nanoseconds() - t
      })
      return times.sort((x, y) => x - y)[K >> 1] / 1000
    }
    // verifySession() runs for every rejected guess too; time it alone as a baseline
    function medianSession(guesses: string[]) {
      const times = guesses.map((g) => {
        const t = Bun.nanoseconds()
        ServerAuth.verifySession(g, config)
        return Bun.nanoseconds() - t
      })
      return times.sort((x, y) => x - y)[K >> 1] / 1000
    }
    median(firstWrong.slice()); median(lastWrong.slice()) // warm up (strings get re-copied below)
    for (const arr of [firstWrong, lastWrong]) for (let i = 0; i < K; i++) arr[i] = flat(arr[i])
    const a = median(firstWrong), b = median(lastWrong)
    const sa = 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")

    Run it: bun timing.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions