blob: d09a3c23b57ac5f867fbebfa3157933b7269e435 [file]
"""Per-client state across a server restart.
The client table lives in a shared memory segment created by post_config,
and that segment does not survive a restart: the restart loop in
server/main.c clears pconf, which destroys the segment, and post_config
then builds a new one, empty.
The nonce does survive, because the secret it is hashed with is kept in
retained data across restarts. So a client returning after a restart
presents a nonce which still verifies, naming per-client state which no
longer exists.
The right answer for such a client is a stale=true challenge: its
credentials were never in doubt, only the server-side state backing its
nonce is gone, so it should retry silently against the fresh nonce rather
than being told its authentication failed. mod_auth_digest does exactly
that when it finds the client unknown (AH10618).
It stopped doing it once the id had been handed to somebody else, which the
ids restarting from 1 made routine rather than exotic: the first clients
seen after a restart took exactly the ids the clients from before it were
still quoting. A returning client was then checked against a *different*
client's entry and reported as a nonce-count failure -- stale=false, which
a browser shows as a failed password.
With one-time nonces it was a replay hole rather than a wrong answer to the
user. Single use is enforced by remembering the last nonce each client
used, and that memory dies with the segment while the nonce does not; the
one-time counter restarts at 0, so a nonce from before the restart outranks
anything the new holder of its id has reached, and was accepted. An
eavesdropper who had captured one used request only had to wait for a
restart.
The ids are now seeded randomly for each segment, so a returning client's
opaque no longer names anybody, and it takes the unknown-client path above.
"""
from . import digest_client as dc
from .env import AAATestEnv
NC_FAILED = "AH01774"
CLIENT_UNKNOWN = "AH10618"
ONETIME_REUSED = "AH01779"
class TestDigestRestart:
def url(self, env, location, path="secret.txt"):
return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
def uri(self, location):
return f"/digest/{location}/secret.txt"
def challenge(self, env, location):
r = env.curl_get(self.url(env, location))
assert r.response["status"] == 401
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
def header(self, location, challenge, nc="00000001", cnonce="restart-cnonce"):
return dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce)
def send(self, env, location, auth):
return env.curl_get(self.url(env, location),
options=["-H", f"Authorization: {auth}"])
def reload(self, env):
assert env.apache_reload() == 0, "graceful restart failed"
def challenge_of(self, r):
"""The challenge carried by a 401 response."""
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
def follow_nextnonce(self, r, challenge):
ai = dc.parse_params(r.response["header"]["authentication-info"])
assert "nextnonce" in ai
challenge.nonce = ai["nextnonce"]
def test_digest_090_returning_client_is_challenged_as_stale(self, env):
# A client which authenticated before a restart comes back afterwards
# with the nonce it was holding. The state naming its opaque is gone,
# so the request cannot be accepted -- but the client did nothing
# wrong, and must be told to retry rather than that it failed.
location = "nccheck"
self.reload(env)
challenge = self.challenge(env, location)
assert self.send(env, location,
self.header(location, challenge)).response["status"] == 200
self.reload(env)
r = self.send(env, location, self.header(location, challenge, "00000002"))
env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
assert r.response["status"] == 401
fresh = self.challenge_of(r)
assert fresh.stale, \
"a client returning after a restart was told its authentication " \
"failed, rather than being asked to retry with a fresh nonce"
# ...and the retry against that fresh challenge succeeds.
assert self.send(env, location,
self.header(location, fresh)).response["status"] == 200
def test_digest_091_returning_client_is_stale_even_if_its_id_was_reused(self, env):
# Same property, but now the id space has caught up: after the restart
# a new client is handed the id the returning client still quotes.
# That is not an unlikely coincidence -- the counter restarts from 1,
# so the first clients seen after a restart take exactly the ids the
# clients from before it are holding.
location = "nccheck"
self.reload(env)
challenge = self.challenge(env, location)
assert self.send(env, location,
self.header(location, challenge)).response["status"] == 200
self.reload(env)
# A different client arrives first and is given the recycled id.
# Today that newcomer is handed the returning client's id, because
# the counter restarts at 1. The assertions below deliberately do not
# require it: a server which stops recycling ids satisfies this
# property by making the returning client simply unknown, which is
# the outcome under test either way.
newcomer = self.challenge(env, location)
assert self.send(env, location,
self.header(location, newcomer)).response["status"] == 200
r = self.send(env, location, self.header(location, challenge, "00000002"))
env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
assert r.response["status"] == 401
assert self.challenge_of(r).stale, \
"a returning client was reported as a possible replay attack " \
"because its id had been given to somebody else"
def test_digest_092_onetime_nonce_from_before_a_restart_is_not_accepted(self, env):
# One-time nonces are ordered by a counter which restarts at 0 with
# the segment, while the nonce itself stays verifiable. A client
# holding an unused nonce from before the restart therefore presents
# a counter value the new server has not reached yet.
location = "onetime"
self.reload(env)
challenge = self.challenge(env, location)
for _ in range(5):
r = self.send(env, location, self.header(location, challenge))
assert r.response["status"] == 200
self.follow_nextnonce(r, challenge)
# challenge.nonce is now an unused nonce, well ahead of the counter
# that a restart will reset to 0.
self.reload(env)
newcomer = self.challenge(env, location)
r = self.send(env, location, self.header(location, newcomer))
assert r.response["status"] == 200
self.follow_nextnonce(r, newcomer)
# The nonce from the previous server generation must not be usable.
stale_use = self.send(env, location, self.header(location, challenge))
env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN,
ONETIME_REUSED])
assert stale_use.response["status"] == 401, \
"a one-time nonce issued before the restart was accepted after it"
# ...and the client which legitimately owns that entry still works.
assert self.send(env, location,
self.header(location, newcomer)).response["status"] == 200, \
"the stale nonce locked out the client holding that id"
def test_digest_093_onetime_request_cannot_be_replayed_across_a_restart(self, env):
# The severity case. A one-time nonce is single-use because the server
# remembers the last nonce each client used -- and that memory does
# not survive a restart, while the nonce does. So an eavesdropper who
# captured one *successfully used* request needs only to wait for a
# restart: the counter it outranks has gone back to zero, and the id
# it names is handed straight back out.
location = "onetime"
self.reload(env)
challenge = self.challenge(env, location)
for _ in range(5):
r = self.send(env, location, self.header(location, challenge))
assert r.response["status"] == 200
captured = self.header(location, challenge) # a used request
self.follow_nextnonce(r, challenge)
assert self.send(env, location, captured).response["status"] == 401, \
"the captured request was not single-use before the restart"
self.reload(env)
# Somebody authenticates, so the recycled id names a live entry.
newcomer = self.challenge(env, location)
assert self.send(env, location,
self.header(location, newcomer)).response["status"] == 200
replay = self.send(env, location, captured)
env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN,
ONETIME_REUSED])
assert replay.response["status"] == 401, \
"a captured one-time request was replayed successfully after a restart"