| """Nonce lifecycle scenarios: tampered nonces, AuthDigestNonceLifetime |
| expiry/reissue, a never-expiring nonce, and the one-time-nonce |
| (AuthDigestNonceLifetime 0) case. |
| """ |
| |
| import time |
| |
| from . import digest_client as dc |
| from .env import AAATestEnv |
| |
| |
| class TestDigestNonce: |
| |
| def url(self, env, location, path="secret.txt"): |
| return env.mkurl("http", "aaa", f"/digest/{location}/{path}") |
| |
| 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 authenticate(self, env, location, challenge, nc="00000001", |
| cnonce="nonce-test-cnonce", uri=None): |
| uri = uri or f"/digest/{location}/secret.txt" |
| auth = dc.build_authorization( |
| AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, |
| method="GET", uri=uri, nc=nc, cnonce=cnonce) |
| return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"]) |
| |
| def test_digest_020_tampered_nonce_is_stale(self, env): |
| challenge = self.challenge(env, "default") |
| # flip a character in the middle of the opaque nonce blob: it stays |
| # the right length but its embedded hash no longer verifies. |
| bad = list(challenge.nonce) |
| mid = len(bad) // 2 |
| bad[mid] = 'x' if bad[mid] != 'x' else 'y' |
| challenge.nonce = ''.join(bad) |
| r = self.authenticate(env, "default", challenge) |
| assert r.response["status"] == 401 |
| new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) |
| assert new_challenge.stale is True |
| env.httpd_error_log.ignore_recent(lognos=["AH01776"]) |
| |
| def test_digest_021_garbage_nonce_hash_is_stale(self, env): |
| # A nonce must still look like "b64(time)+hex(hash)" (VALID_NONCE in |
| # mod_auth_digest.c checks length and the '=' padding boundary) to |
| # even be considered for a hash check; something that doesn't match |
| # that shape is instead rejected as a malformed header (see |
| # test_digest_010). Here we keep the genuine time-prefix (so the |
| # shape is valid) but replace the whole hash suffix with garbage, to |
| # hit check_nonce()'s "hash is not %s" path distinctly from |
| # test_digest_020's single-flipped-character tamper. |
| # |
| # The hash length depends on which keyed hash the module was built |
| # with, so take it from the nonce rather than assuming: the time is |
| # base64 of 8 bytes and so ends with the only '=' in the string. |
| challenge = self.challenge(env, "default") |
| time_prefix = challenge.nonce[:challenge.nonce.index('=') + 1] |
| hash_len = len(challenge.nonce) - len(time_prefix) |
| challenge.nonce = time_prefix + ("f" * hash_len) |
| r = self.authenticate(env, "default", challenge) |
| assert r.response["status"] == 401 |
| new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) |
| assert new_challenge.stale is True |
| env.httpd_error_log.ignore_recent(lognos=["AH01776"]) |
| |
| def test_digest_022_short_lifetime_expires(self, env): |
| # AuthDigestNonceLifetime 2 for this location. |
| challenge = self.challenge(env, "shortlife") |
| r = self.authenticate(env, "shortlife", challenge) |
| assert r.response["status"] == 200 |
| |
| time.sleep(3) |
| # same nonce, now past its lifetime -> 401 stale=true |
| r = self.authenticate(env, "shortlife", challenge) |
| assert r.response["status"] == 401 |
| stale_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) |
| assert stale_challenge.stale is True |
| env.httpd_error_log.ignore_recent(lognos=["AH01776"]) |
| |
| # the fresh nonce from the stale challenge works again |
| r = self.authenticate(env, "shortlife", stale_challenge) |
| assert r.response["status"] == 200 |
| |
| def test_digest_023_never_expiring_nonce(self, env): |
| # AuthDigestNonceLifetime -1 for this location: no NcCheck is |
| # configured, so the identical Authorization line can simply be |
| # replayed after a delay and must still succeed both times. |
| challenge = self.challenge(env, "neverexpire") |
| r1 = self.authenticate(env, "neverexpire", challenge) |
| assert r1.response["status"] == 200 |
| |
| time.sleep(3) |
| r2 = self.authenticate(env, "neverexpire", challenge) |
| assert r2.response["status"] == 200 |
| |
| def test_digest_024_one_time_nonce_rejects_reuse(self, env): |
| # AuthDigestNonceLifetime 0: a successful request immediately |
| # supersedes its nonce (the tracked "last_nonce" moves on to the |
| # nextnonce from Authentication-Info), so replaying the very same |
| # nonce right afterwards must fail as stale. Each request against |
| # this client (success OR failure) advances the tracked nonce again, |
| # so this test does exactly one success followed by exactly one |
| # reuse -- no longer chain that would need to account for that. |
| challenge = self.challenge(env, "onetime") |
| assert challenge.opaque is not None, \ |
| "one-time-nonce tracking requires an opaque to identify the client" |
| |
| r1 = self.authenticate(env, "onetime", challenge) |
| assert r1.response["status"] == 200 |
| ai1 = dc.parse_params(r1.response["header"]["authentication-info"]) |
| assert "nextnonce" in ai1 |
| assert ai1["nextnonce"] != challenge.nonce |
| |
| # reusing the exact same (now superseded) nonce fails as stale |
| r2 = self.authenticate(env, "onetime", challenge) |
| assert r2.response["status"] == 401 |
| stale_challenge = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"]) |
| assert stale_challenge.stale is True |
| env.httpd_error_log.ignore_recent(lognos=["AH01776"]) |
| |
| def test_digest_025_one_time_nonce_chain_continues(self, env): |
| # Following the nextnonce handed out on a successful response lets |
| # the client keep authenticating, one hop at a time. |
| challenge = self.challenge(env, "onetime") |
| r1 = self.authenticate(env, "onetime", challenge) |
| assert r1.response["status"] == 200 |
| ai1 = dc.parse_params(r1.response["header"]["authentication-info"]) |
| |
| challenge.nonce = ai1["nextnonce"] |
| r2 = self.authenticate(env, "onetime", challenge) |
| assert r2.response["status"] == 200 |
| ai2 = dc.parse_params(r2.response["header"]["authentication-info"]) |
| assert ai2["nextnonce"] != ai1["nextnonce"] |