v4.169.49 - timing-safe credential comparison that non-ASCII input cannot crash (#61, #63)
A non-ASCII credential crashed the auth path (#61)
Issue #61 was filed against a docstring: AgentRegistry.resolve_token
claimed constant-time comparison "via hmac.compare_digest inside
_derive_agent_token" while resolving tokens with a plain dict lookup.
_derive_agent_token mints tokens with hmac.new; it compares nothing.
Applying the fix the issue suggests -- hmac.compare_digest in the
lookup loop -- would have introduced a worse bug than it fixed, because
that function raises on non-ASCII str:
TypeError: comparing strings with non-ASCII characters is not supported
Checking that hazard against the eight existing comparison sites found
it was already live. Measured against the running bridge, with no
credential at all:
GET /v1/status Authorization: Bearer <u-umlaut> -> 500
GET /v1/status Authorization: Bearer agent-<uml>-attack -> 500
GET /gui?token=%C3%BC -> 500
GET /v1/status Authorization: Bearer agent-deadbeef-... -> 401
An anonymous caller could turn any auth check into an unhandled
exception on paths whose entire job is to answer 401 -- an
error-response amplifier and a liveness bug, reachable from the public
internet whenever a tunnel is up.
The fix is arena/auth/compare.py: secrets_equal compares UTF-8
bytes, which compare_digest accepts for every input, so the
comparison stays timing-safe and becomes total. Non-string operands
(None, a dict decoded from JSON) compare False instead of raising.
Lone surrogates, which a latin-1-decoded header can carry, are encoded
with surrogatepass rather than crashing on the way in.
Applied at every credential comparison: the master-token path, the
per-user roster, both GUI login routes, the public-tunnel
acknowledgement, and the signed URL cache. arena/input_helper/ helper_server.py carries its own copy on purpose -- it runs as a
standalone script outside the package -- with a test asserting the two
agree.
And #61 proper: resolve_token now scans every registered token
without breaking early, so the number of comparisons no longer depends
on where the match sits, and the docstring describes what the code
actually does.
The unauthenticated /v1/version no longer fingerprints the host (#63)
Captured from the live bridge, with no token:
$ curl -s http://127.0.0.1:8765/v1/version
{"ok": true, "version": "4.169.48", "service": "arena-unified-bridge",
"python": "3.14.7", "platform": "Windows-10-10.0.19044-SP0",
"loopback_only": true, "deployment": {...}}
"Python 3.14.7 on Windows-10-10.0.19044-SP0" is not trivia. It names the
exact CVE set worth trying against this host -- interpreter patch level
and OS build number are the two inputs a vulnerability scanner most wants
-- and anyone who could reach the port could read it for free.
python and platform are now gated on authentication rather than
deleted. They have a real operator use, and authenticated callers already
receive both from common_status() on /v1/info and /v1/status, so
gating costs no function. An authenticated /v1/version additionally
returns the full deployment record instead of the public subset.
What deliberately did not change: version stays public. /health,
/v2/health, /, /metrics and /api-docs all publish it, installers
read it before they hold a token, and the Android status screen shows it.
Truncating it on this one route would have cost real function while
hiding nothing -- a fix that only looks like one.
The route also keeps answering anonymous callers with 200. The auth check
here reads the credential without refusing the caller: require_auth
returns 401 and counts toward the 10-failures-in-60s throttle, so using
it on a by-design-public route would have rate-limited the Android app,
which polls /v1/version with no token because it cannot read the token
file under a different UID. A probe that raises, or wiring that forgets
to supply one, falls back to the anonymous body -- the failure mode
discloses less, never more.
Two follow-ups the review surfaced, both verified by measurement rather
than argument. check_auth now returns early when the caller presented
no credential at all: it used to fall through to the roster lookup,
which is pure cost for a request that cannot match anything. And
UserStore.load_users caches an empty roster like any other -- the TTL
check previously required a non-empty dict, so a bridge with an empty
or absent users.json re-read it on every auth check (measured: 100
reads per 100 calls; 1 per 100 once a user exists). Dormant while only
authenticated routes probed auth, but /v1/version is public and
polled, so gating it would have handed anonymous callers a disk-I/O
amplifier. A damaged file is still not cached: a roster somebody is
repairing should be retried. A probe that raises is now logged at
WARNING instead of failing closed in silence.
Verification
Built twice from 13cc94496d36a50db1db65ffc032b6a291098a6d in
candidate run 32578284088;
independent builds A and B matched byte-for-byte.
arena-agent-v4.169.49.zip 46293f9153585bffab04a22da86816b10f39156e3451f8e0b02ddc909fbbdc1c
arena-agent.zip 46293f9153585bffab04a22da86816b10f39156e3451f8e0b02ddc909fbbdc1c
arena-bridge.apk 32bb7d96dad6a26c6107bfb8f8edbcc303708b39be433eff4d251b3d9b42c13b
Both ZIPs are byte-identical; the alias is a copy, not a rebuild.
Build provenance and the SPDX SBOM predicate were verified for all three
assets against the pinned release-candidate.yml signer workflow with
--source-digest bound to the exact commit and --deny-self-hosted-runners.
The check was confirmed to be meaningful rather than vacuous: verification
with a wrong source digest and with a different signer workflow both fail.
Full suite on the released commit: 9337 passed, 36 skipped.