You are viewing a plain text version of this content. The canonical link for it is here.
Posted to cvs@httpd.apache.org by jo...@apache.org on 2026/08/13 15:15:08 UTC

svn commit: r1937104 - in httpd/httpd/trunk: modules/aaa test/modules/aaa test/modules/aaa/htdocs/digest/nccheck-shortlife

Author: jorton
Date: Thu Aug 13 15:15:07 2026
New Revision: 1937104

Log:
* modules/aaa/mod_auth_digest.c: Fix AuthDigestNcCheck: track the
  nonce-count per-nonce, require it to increase (RFC 7616 3.4.3), and
  record it only for a fully verified request.

* test/modules/aaa/test_007_replay.py: Add replay tests.
* test/modules/aaa/test_003_nccheck.py, test/modules/aaa/conftest.py:
  Update for the new semantics.

Assisted-by: Claude Opus 5 (1M context) <no...@anthropic.com>
GitHub: PR #705

Added:
   httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/
   httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
   httpd/httpd/trunk/test/modules/aaa/test_007_replay.py
Modified:
   httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
   httpd/httpd/trunk/test/modules/aaa/conftest.py
   httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py

Modified: httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
==============================================================================
--- httpd/httpd/trunk/modules/aaa/mod_auth_digest.c	Thu Aug 13 15:14:58 2026	(r1937103)
+++ httpd/httpd/trunk/modules/aaa/mod_auth_digest.c	Thu Aug 13 15:15:07 2026	(r1937104)
@@ -118,7 +118,10 @@ typedef struct digest_config_struct {
 typedef struct hash_entry {
     unsigned long      key;                     /* the key for this entry    */
     struct hash_entry *next;                    /* next entry in the bucket  */
-    unsigned long      nonce_count;             /* for nonce-count checking  */
+    unsigned long      nonce_count;             /* highest nonce-count seen
+                                                 * for last_nonce_time       */
+    apr_time_t         last_nonce_time;         /* nonce of the last request
+                                                 * accepted for this client  */
     char               last_nonce[NONCE_LEN+1]; /* for one-time nonce's      */
 } client_entry;
 
@@ -932,21 +935,22 @@ static int get_digest_rec(request_rec *r
 }
 
 
-/* Because the browser may preemptively send auth info, incrementing the
- * nonce-count when it does, and because the client does not get notified
- * if the URI didn't need authentication after all, we need to be sure to
- * update the nonce-count each time we receive an Authorization header no
- * matter what the final outcome of the request. Furthermore this is a
- * convenient place to get the request-uri (before any subrequests etc
- * are initiated) and to initialize the request_config.
+/* This is a convenient place to parse the Authorization header, to get the
+ * request-uri (before any subrequests etc are initiated) and to initialize
+ * the request_config.
+ *
+ * Note that the nonce-count tracked for the client is deliberately NOT
+ * updated here: the state of an authenticated client must not be altered
+ * by a request which has not (yet) been authenticated, or a replayed or
+ * bogus request quoting the client's opaque would be able to rewind that
+ * state. See check_and_record_nonce().
  *
  * Note that this must be called after mod_proxy had its go so that
  * r->proxyreq is set correctly.
  */
-static int parse_hdr_and_update_nc(request_rec *r)
+static int parse_digest_header(request_rec *r)
 {
     digest_header_rec *resp;
-    int res;
 
     if (!ap_is_initial_req(r)) {
         return DECLINED;
@@ -959,11 +963,8 @@ static int parse_hdr_and_update_nc(reque
     resp->method = r->method;
     ap_set_module_config(r->request_config, &auth_digest_module, resp);
 
-    res = get_digest_rec(r, resp);
+    get_digest_rec(r, resp);
     resp->client = get_client(resp->opaque_num, r);
-    if (res == OK && resp->client) {
-        resp->client->nonce_count++;
-    }
 
     return DECLINED;
 }
@@ -1028,7 +1029,7 @@ static const char *gen_nonce(apr_pool_t
 static client_entry *gen_client(const request_rec *r)
 {
     apr_uint32_t op = apr_atomic_inc32(opaque_counter);
-    client_entry new_entry = { 0, NULL, 0, "" }, *entry;
+    client_entry new_entry = { 0, NULL, 0, 0, "" }, *entry;
 
     if (!(entry = add_client(op, &new_entry, r->server))) {
         ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01769)
@@ -1088,9 +1089,11 @@ static void note_digest_auth_failure(req
         }
     }
     else {
+        /* Note that the nonce-count tracked for this client is left alone
+         * here: the client may not even see this challenge (it may have
+         * been triggered by somebody else quoting its opaque), and it is
+         * tied to the nonce it was counted for in any case. */
         opaque = resp->opaque;
-        /* we're generating a new nonce, so reset the nonce-count */
-        resp->client->nonce_count = 0;
     }
 
     if (opaque[0]) {
@@ -1233,12 +1236,33 @@ static authn_status get_hash(request_rec
     return auth_result;
 }
 
-static int check_nc(const request_rec *r, const digest_header_rec *resp,
-                    const digest_config_rec *conf)
+/* Check the nonce-count of a request against the count tracked for this
+ * client, and update the tracked count.
+ *
+ * The nonce-count is counted by the client per-nonce (RFC 7616 3.4.3), so
+ * the count tracked here is tied to the nonce it was counted for: a request
+ * using a newer nonce starts a new count, and a request using an older
+ * nonce is replaying a superseded one. Within a single nonce the count must
+ * strictly increase, but it need not increase by exactly one: the client
+ * also counts the requests it sends to URIs in the protection space which
+ * turn out not to need authentication, and this server never sees those.
+ *
+ * This must only be called for a request which is fully verified - both the
+ * response digest and the nonce - so that a request which fails to
+ * authenticate cannot alter the state tracked for the client whose opaque
+ * it quotes. Otherwise a bogus or replayed request could rewind the count
+ * and so lock out the legitimate client, and line the count up with the
+ * nonce-count of the replayed request itself.
+ */
+static int check_and_record_nonce(const request_rec *r,
+                               const digest_header_rec *resp,
+                               const digest_config_rec *conf)
 {
-    unsigned long nc;
+    client_entry *client = resp->client;
+    unsigned long nc, tracked;
     const char *snc = resp->nonce_count;
     char *endptr;
+    int accepted;
 
     if (!conf->check_nc) {
         return OK;
@@ -1251,15 +1275,33 @@ static int check_nc(const request_rec *r
         return !OK;
     }
 
-    if (!resp->client) {
+    if (!client) {
+        /* Without an opaque identifying the client there is nothing to
+         * check the nonce-count against. */
         return !OK;
     }
 
-    if (nc != resp->client->nonce_count) {
+    apr_global_mutex_lock(client_lock);
+
+    tracked = client->nonce_count;
+
+    /* Accept, and record, iff the client has moved on to a newer nonce (in
+     * which case this is the first request counted for that nonce), or is
+     * still on the tracked nonce and has raised the count. */
+    accepted = (resp->nonce_time > client->last_nonce_time)
+               || (resp->nonce_time == client->last_nonce_time && nc > tracked);
+    if (accepted) {
+        client->last_nonce_time = resp->nonce_time;
+        client->nonce_count = nc;
+    }
+
+    apr_global_mutex_unlock(client_lock);
+
+    if (!accepted) {
         ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01774)
-                      "Warning, possible replay attack: nonce-count "
-                      "check failed: %lu != %lu", nc,
-                      resp->client->nonce_count);
+                      "Warning, possible replay attack: nonce-count check "
+                      "failed: %lu is not above %lu for nonce %s", nc,
+                      tracked, resp->nonce);
         return !OK;
     }
 
@@ -1621,17 +1663,20 @@ static int authenticate_digest_user(requ
         }
     }
 
-    if (check_nc(r, resp, conf) != OK) {
-        note_digest_auth_failure(r, conf, resp, 0);
-        return HTTP_UNAUTHORIZED;
-    }
-
-    /* Note: this check is done last so that a "stale=true" can be
-       generated if the nonce is old */
+    /* Note: the nonce is checked before the nonce-count so that the
+     * nonce-count state is only ever updated for a request which is using
+     * a nonce this server issued, and so that a request using an expired
+     * nonce gets a "stale=true" challenge (and hence a silent retry with a
+     * fresh nonce-count) rather than being reported as a replay. */
     if ((res = check_nonce(r, resp, conf))) {
         return res;
     }
 
+    if (check_and_record_nonce(r, resp, conf) != OK) {
+        note_digest_auth_failure(r, conf, resp, 0);
+        return HTTP_UNAUTHORIZED;
+    }
+
     return OK;
 }
 
@@ -1665,8 +1710,6 @@ static int add_auth_info(request_rec *r)
                                    gen_nonce(r->pool, r->request_time,
                                              resp->opaque, r->server, conf, ap_auth_name(r)),
                                    "\"", NULL);
-            if (resp->client)
-                resp->client->nonce_count = 0;
         }
     }
     else if (conf->nonce_lifetime == 0 && resp->client) {
@@ -1733,7 +1776,7 @@ static void register_hooks(apr_pool_t *p
     ap_hook_pre_config(pre_init, NULL, NULL, APR_HOOK_MIDDLE);
     ap_hook_post_config(initialize_module, NULL, cfgPost, APR_HOOK_MIDDLE);
     ap_hook_child_init(initialize_child, NULL, NULL, APR_HOOK_MIDDLE);
-    ap_hook_post_read_request(parse_hdr_and_update_nc, parsePre, NULL, APR_HOOK_MIDDLE);
+    ap_hook_post_read_request(parse_digest_header, parsePre, NULL, APR_HOOK_MIDDLE);
     ap_hook_check_authn(authenticate_digest_user, NULL, NULL, APR_HOOK_MIDDLE,
                         AP_AUTH_INTERNAL_PER_CONF);
 

Modified: httpd/httpd/trunk/test/modules/aaa/conftest.py
==============================================================================
--- httpd/httpd/trunk/test/modules/aaa/conftest.py	Thu Aug 13 15:14:58 2026	(r1937103)
+++ httpd/httpd/trunk/test/modules/aaa/conftest.py	Thu Aug 13 15:15:07 2026	(r1937104)
@@ -52,6 +52,12 @@ def env(pytestconfig) -> AAATestEnv:
         f'AuthUserFile "{pwfile}"',
         'AuthDigestNcCheck On',
     ]))
+    conf.add(_digest_dir(docs, "nccheck-shortlife", [
+        'AuthDigestProvider file',
+        f'AuthUserFile "{pwfile}"',
+        'AuthDigestNcCheck On',
+        'AuthDigestNonceLifetime 2',
+    ]))
     conf.add(_digest_dir(docs, "shortlife", [
         'AuthDigestProvider file',
         f'AuthUserFile "{pwfile}"',

Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt	Thu Aug 13 15:15:07 2026	(r1937104)
@@ -0,0 +1 @@
+digest-nccheck-secret

Modified: httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py
==============================================================================
--- httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py	Thu Aug 13 15:14:58 2026	(r1937103)
+++ httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py	Thu Aug 13 15:15:07 2026	(r1937104)
@@ -1,17 +1,17 @@
 """AuthDigestNcCheck replay-detection scenarios.
 
-Note the actual semantics here are stricter than a sliding replay window:
-the server keeps its own count of authenticated requests seen for a client
-(incremented on *every* request carrying that client's opaque, whether or
-not it goes on to authenticate) and requires the client's nc to match it
-*exactly* -- so both replays of an old nc and skipping ahead are rejected.
-A failed nc check also resets the server's tracked count back to 0, as part
-of issuing a fresh challenge for the client (see note_digest_auth_failure()
-in mod_auth_digest.c: an existing, opaque-identified client always gets its
-nonce_count reset when a new challenge is generated for it, regardless of
-*why* the challenge is being reissued) -- so recovery after a rejected nc
-means starting the sequence over at 00000001, not continuing where the
-client left off.
+The semantics are those of RFC 7616 3.4.3: the nonce-count is counted by
+the client per-nonce, so the server tracks a count per (client, nonce) pair
+and requires it to strictly increase. Within one nonce, an nc which has
+already been seen is a replay and is rejected; a *higher* nc than expected
+is not, since the client also counts the requests it sends to URIs in the
+protection space which turn out not to need authentication, and the server
+never sees those. Moving to a newer nonce starts a fresh count, and a nonce
+the client has already moved on from is rejected.
+
+The tracked count is only ever updated for a fully verified request, so a
+failed request cannot disturb the count of the client whose opaque it
+quotes; test_007_replay.py covers that property directly.
 """
 
 from . import digest_client as dc
@@ -40,13 +40,17 @@ class TestDigestNcCheck:
     def test_digest_030_nccheck_requires_opaque(self, env):
         # with AuthDigestNcCheck on, the server cannot verify nc without
         # having tracked this client via its opaque -- omitting the opaque
-        # therefore fails the check outright, even with nc=00000001.
+        # therefore fails, even with nc=00000001. It is rejected before the
+        # nc check is even reached: the nonce hash is computed over the
+        # opaque (gen_nonce_hash()), so a nonce quoted without the opaque it
+        # was issued with does not verify, and that is reported as stale.
         challenge = self.challenge(env, "nccheck")
         assert challenge.opaque is not None
         r = self.authenticate(env, "nccheck", challenge, nc="00000001", include_opaque=False)
         assert r.response["status"] == 401
         new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
-        assert new_challenge.stale is False
+        assert new_challenge.stale is True
+        env.httpd_error_log.ignore_recent(lognos=["AH01776"])
 
     def test_digest_031_nccheck_sequential_ok(self, env):
         challenge = self.challenge(env, "nccheck")
@@ -72,21 +76,34 @@ class TestDigestNcCheck:
         assert new_challenge.stale is False
         env.httpd_error_log.ignore_recent(lognos=["AH01774"])
 
-        # the rejected attempt reset the server's tracked count to 0 (a new
-        # challenge was issued for this client), so recovery restarts the
-        # sequence at 00000001 -- continuing from 00000003 would NOT work.
-        r4 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+        # recovery: the rejected attempt handed out a fresh challenge for
+        # this client, and following it -- new nonce, so the count starts
+        # over at 00000001 -- authenticates again.
+        r4 = self.authenticate(env, "nccheck", new_challenge, nc="00000001")
         assert r4.response["status"] == 200
 
-    def test_digest_033_nccheck_skip_ahead_rejected(self, env):
+        # the superseded nonce is not usable any more, at any nc.
+        r5 = self.authenticate(env, "nccheck", challenge, nc="00000003")
+        assert r5.response["status"] == 401
+        env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+    def test_digest_033_nccheck_skip_ahead_allowed(self, env):
         challenge = self.challenge(env, "nccheck")
         r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
         assert r1.response["status"] == 200
 
-        # skipping ahead is rejected too: nc must match exactly, not just
-        # be higher than what was last accepted.
+        # skipping ahead is allowed: nc only has to be higher than the
+        # highest already seen for this nonce, not exactly one more. A
+        # client legitimately produces gaps by sending counted requests to
+        # URIs in the protection space which don't need authentication, and
+        # a higher nc is not a replay in any case.
         r2 = self.authenticate(env, "nccheck", challenge, nc="00000009")
-        assert r2.response["status"] == 401
+        assert r2.response["status"] == 200
+
+        # ...and the skipped-over counts are spent: they are no longer
+        # accepted afterwards.
+        r3 = self.authenticate(env, "nccheck", challenge, nc="00000005")
+        assert r3.response["status"] == 401
         env.httpd_error_log.ignore_recent(lognos=["AH01774"])
 
     def test_digest_034_no_nccheck_allows_replay(self, env):

Added: httpd/httpd/trunk/test/modules/aaa/test_007_replay.py
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_007_replay.py	Thu Aug 13 15:15:07 2026	(r1937104)
@@ -0,0 +1,237 @@
+"""Replay-attack scenarios against AuthDigestNcCheck.
+
+AuthDigestNcCheck exists to detect replayed requests: the server tracks the
+highest nonce-count it has accepted from a client (identified by its opaque)
+for the nonce that client is using, and requires each request to raise it.
+
+The security property under test here is not just "the replayed request is
+rejected", but that rejecting it must not damage the legitimate client:
+
+    With nonce-count checking enabled, a replay attack MUST NOT affect the
+    original (legitimate) client by resetting its nonce count.
+
+It used to. On a failed authentication mod_auth_digest issues a fresh
+challenge via note_digest_auth_failure(), and for an already-known
+(opaque-identified) client that path reset client->nonce_count to 0, while
+the post_read_request hook re-incremented the count from 0 on the next
+request carrying that opaque. An attacker who could make *any* request fail
+for the victim's opaque therefore rewound the victim's counter, with two
+consequences:
+
+  * the legitimate client's next in-sequence nc no longer matched, so it
+    was locked out (denial of service against the victim), and
+  * the attacker's replayed request lined up with the rewound counter and
+    was accepted -- 200, 401, 200, 401, ... for one captured header, or
+    every time if the attacker rewound the counter deliberately first.
+
+The count is now tracked per (client, nonce) and updated only for a request
+which has been fully verified, so a request which fails to authenticate
+leaves the victim's state untouched.
+"""
+
+import time
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+# See the note in test_003_nccheck.py: a failed nc check is not reported as
+# stale, since it is a distinct failure mode from an invalid/expired nonce.
+NC_FAILED = "AH01774"
+NONCE_HASH_INVALID = "AH01776"
+PASSWORD_MISMATCH = "AH01794"
+
+
+class TestDigestReplay:
+
+    LOCATION = "nccheck"
+
+    def url(self, env, path="secret.txt"):
+        return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/{path}")
+
+    @property
+    def uri(self):
+        return f"/digest/{self.LOCATION}/secret.txt"
+
+    def challenge(self, env):
+        r = env.curl_get(self.url(env))
+        assert r.response["status"] == 401
+        return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+    def victim_header(self, challenge, nc, cnonce="victim-cnonce"):
+        """A correct Authorization header from the legitimate client."""
+        return dc.build_authorization(
+            AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+            method="GET", uri=self.uri, nc=nc, cnonce=cnonce)
+
+    def attacker_header(self, challenge, nc="00000001", cnonce="attacker-cnonce"):
+        """A well-formed Digest header carrying the victim's opaque and nonce
+        but a bogus response digest. An attacker who has merely *seen* one of
+        the victim's requests can build this; no credentials are needed."""
+        return dc.build_authorization(
+            AAATestEnv.DIGEST_USER, challenge, "not-the-password",
+            method="GET", uri=self.uri, nc=nc, cnonce=cnonce,
+            response="0" * 32)
+
+    def send(self, env, auth):
+        return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+
+    def test_digest_070_replay_does_not_lock_out_legit_client(self, env):
+        # The legitimate client authenticates a few times, in sequence.
+        challenge = self.challenge(env)
+        for nc in ["00000001", "00000002", "00000003"]:
+            assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200
+
+        # An attacker replays a request captured earlier in that sequence.
+        # Rejecting it is correct...
+        replayed = self.victim_header(challenge, "00000002")
+        replay_status = self.send(env, replayed).response["status"]
+
+        # ...but it must not disturb the legitimate client, which knows
+        # nothing of the replay and simply carries on with its next nc.
+        r = self.send(env, self.victim_header(challenge, "00000004"))
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+        assert replay_status == 401
+        assert r.response["status"] == 200, \
+            "the replay reset the victim's nonce-count and locked it out"
+
+    def test_digest_071_bogus_request_does_not_lock_out_legit_client(self, env):
+        # Same property, but the attacker does not even need to have captured
+        # a complete valid request: any well-formed Digest header quoting the
+        # victim's opaque is enough to rewind the victim's counter.
+        challenge = self.challenge(env)
+        for nc in ["00000001", "00000002"]:
+            assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200
+
+        bogus_status = self.send(env, self.attacker_header(challenge)).response["status"]
+
+        r = self.send(env, self.victim_header(challenge, "00000003"))
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH])
+        assert bogus_status == 401
+        assert r.response["status"] == 200, \
+            "a bogus request reset the victim's nonce-count and locked it out"
+
+    def test_digest_072_captured_request_is_never_accepted_twice(self, env):
+        # The flip side of the same defect. One captured Authorization header
+        # is replayed verbatim; the first send is the genuine request, so it
+        # succeeds, and every later send must be rejected. Before the fix the
+        # rejection rewound the counter, so the replay after it lined up
+        # again: the observed pattern was 200, 401, 200, 401, ...
+        challenge = self.challenge(env)
+        captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce")
+
+        assert self.send(env, captured).response["status"] == 200
+        statuses = [self.send(env, captured).response["status"] for _ in range(4)]
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+        assert statuses == [401, 401, 401, 401], \
+            f"replayed request was accepted again: {statuses}"
+
+    def test_digest_073_attacker_cannot_force_replay_to_succeed(self, env):
+        # Severity check: the attacker must not be able to line the counter
+        # up on demand. Before the fix, sending a bogus request first rewound
+        # the counter to 0, so the replay that followed succeeded every
+        # single time.
+        challenge = self.challenge(env)
+        captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce")
+        assert self.send(env, captured).response["status"] == 200
+
+        statuses = []
+        for _ in range(3):
+            self.send(env, self.attacker_header(challenge))
+            statuses.append(self.send(env, captured).response["status"])
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH])
+        assert statuses == [401, 401, 401], \
+            f"attacker replayed at will by forcing a counter reset: {statuses}"
+
+    def test_digest_074_legit_client_recovers_via_fresh_challenge(self, env):
+        # Invariant: a client whose nc is rejected is handed a fresh
+        # challenge, and following that challenge -- new nonce, so the count
+        # starts over at 1 -- gets it working again. Simply never resetting
+        # the count, without tying it to the nonce it was counted for, would
+        # break this.
+        challenge = self.challenge(env)
+        assert self.send(env, self.victim_header(challenge, "00000001")).response["status"] == 200
+
+        # provoke the rejection with a replay of that first request
+        r = self.send(env, self.victim_header(challenge, "00000001"))
+        assert r.response["status"] == 401
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+        fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+        assert fresh.stale is False
+        assert fresh.opaque == challenge.opaque, \
+            "the client keeps its identity across a re-challenge"
+        assert fresh.nonce != challenge.nonce
+
+        r = self.send(env, self.victim_header(fresh, "00000001"))
+        assert r.response["status"] == 200
+
+    def test_digest_075_nonce_is_bound_to_opaque(self, env):
+        # A captured header cannot be re-pointed at a *different* client
+        # session to dodge that session's nonce-count: the nonce hash is
+        # computed over the opaque (gen_nonce_hash()), so quoting one
+        # client's nonce under another client's opaque fails the hash check
+        # outright, and is reported as stale.
+        victim = self.challenge(env)
+        captured = self.victim_header(victim, "00000001", cnonce="captured-cnonce")
+        assert self.send(env, captured).response["status"] == 200
+
+        attacker = self.challenge(env)
+        assert attacker.opaque != victim.opaque
+        spliced = dc.build_authorization(
+            AAATestEnv.DIGEST_USER, victim, AAATestEnv.DIGEST_PASSWORD,
+            method="GET", uri=self.uri, nc="00000001", cnonce="captured-cnonce",
+            opaque=attacker.opaque)
+        r = self.send(env, spliced)
+        env.httpd_error_log.ignore_recent(lognos=[NONCE_HASH_INVALID])
+        assert r.response["status"] == 401
+        assert dc.DigestChallenge.parse(
+            r.response["header"]["www-authenticate"]).stale is True
+
+
+class TestDigestNcCheckExpiry:
+    """AuthDigestNcCheck combined with an expiring nonce.
+
+    The nonce is checked before the nonce-count, so that an expired nonce
+    still produces a "stale=true" challenge rather than being reported as a
+    replay -- the client then retries silently against the fresh nonce, with
+    its count restarted at 1.
+    """
+
+    LOCATION = "nccheck-shortlife"   # AuthDigestNcCheck On, lifetime 2s
+
+    def url(self, env):
+        return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/secret.txt")
+
+    def challenge(self, env):
+        r = env.curl_get(self.url(env))
+        assert r.response["status"] == 401
+        return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+    def send(self, env, challenge, nc):
+        auth = dc.build_authorization(
+            AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+            method="GET", uri=f"/digest/{self.LOCATION}/secret.txt", nc=nc,
+            cnonce="expiry-cnonce")
+        return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+
+    def test_digest_076_expired_nonce_restarts_the_count(self, env):
+        challenge = self.challenge(env)
+        assert self.send(env, challenge, "00000001").response["status"] == 200
+        assert self.send(env, challenge, "00000002").response["status"] == 200
+
+        time.sleep(3)
+
+        # past its lifetime: reported as stale, not as a nonce-count failure
+        r = self.send(env, challenge, "00000003")
+        assert r.response["status"] == 401
+        fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+        assert fresh.stale is True
+
+        # the client restarts its count for the fresh nonce, which must not
+        # collide with the count already tracked for the expired one
+        assert self.send(env, fresh, "00000001").response["status"] == 200
+        assert self.send(env, fresh, "00000002").response["status"] == 200
+
+        # and the expired nonce stays unusable
+        r = self.send(env, challenge, "00000004")
+        assert r.response["status"] == 401
+        env.httpd_error_log.ignore_recent(lognos=["AH01776", NC_FAILED])