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:20 UTC

svn commit: r1937105 - in httpd/httpd/trunk: docs/log-message-tags modules/aaa test/modules/aaa test/modules/aaa/htdocs/digest/onetime-nccheck

Author: jorton
Date: Thu Aug 13 15:15:19 2026
New Revision: 1937105

Log:
* modules/aaa/mod_auth_digest.c: Consolidate one-time nonce handling
  in check_and_record_nonce, accepting a nonce iff it is newer than
  the last used, and re-challenging as stale when reused.

* test/modules/aaa/test_008_onetime_nccheck.py: Add tests for one-time
  nonces, alone and with AuthDigestNcCheck.

* test/modules/aaa/conftest.py: Add an AuthDigestNonceLifetime 0 plus
  AuthDigestNcCheck location.

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

Added:
   httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime-nccheck/
   httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt
   httpd/httpd/trunk/test/modules/aaa/test_008_onetime_nccheck.py
Modified:
   httpd/httpd/trunk/docs/log-message-tags/next-number
   httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
   httpd/httpd/trunk/test/modules/aaa/conftest.py

Modified: httpd/httpd/trunk/docs/log-message-tags/next-number
==============================================================================
--- httpd/httpd/trunk/docs/log-message-tags/next-number	Thu Aug 13 15:15:07 2026	(r1937104)
+++ httpd/httpd/trunk/docs/log-message-tags/next-number	Thu Aug 13 15:15:19 2026	(r1937105)
@@ -1 +1 @@
-10618
+10619

Modified: httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
==============================================================================
--- httpd/httpd/trunk/modules/aaa/mod_auth_digest.c	Thu Aug 13 15:15:07 2026	(r1937104)
+++ httpd/httpd/trunk/modules/aaa/mod_auth_digest.c	Thu Aug 13 15:15:19 2026	(r1937105)
@@ -122,7 +122,6 @@ typedef struct hash_entry {
                                                  * 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;
 
 static struct hash_table {
@@ -139,6 +138,14 @@ static struct hash_table {
 
 enum hdr_sts { NO_HEADER, NOT_DIGEST, INVALID, VALID };
 
+/* Outcome of checking a request's nonce and nonce-count against the state
+ * tracked for its client. */
+enum nonce_state {
+    NONCE_ACCEPTED,     /* recorded as the latest used by this client */
+    NONCE_STALE,        /* already used, or the client is unknown */
+    NONCE_BAD_COUNT     /* nonce-count did not increase: possible replay */
+};
+
 typedef struct digest_header_struct {
     const char           *scheme;
     const char           *realm;
@@ -1009,7 +1016,10 @@ static const char *gen_nonce(apr_pool_t
         t.time = now;
     }
     else {
-        t.time = apr_atomic_inc32(otn_counter);
+        /* Nonces are ordered by this counter rather than by time; the +1
+         * is because apr_atomic_inc32() returns the previous value, and a
+         * nonce time of zero means "no nonce used yet" in a client entry. */
+        t.time = apr_atomic_inc32(otn_counter) + 1;
     }
     apr_base64_encode_binary(nonce, t.arr, sizeof(t.arr));
     gen_nonce_hash(nonce+NONCE_TIME_LEN, nonce, opaque, server, conf, realm);
@@ -1029,7 +1039,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, 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)
@@ -1106,9 +1116,6 @@ static void note_digest_auth_failure(req
     /* Setup nonce */
 
     nonce = gen_nonce(r->pool, r->request_time, opaque, r->server, conf, ap_auth_name(r));
-    if (resp->client && conf->nonce_lifetime == 0) {
-        memcpy(resp->client->last_nonce, nonce, NONCE_LEN+1);
-    }
 
     /* setup domain attribute. We want to send this attribute wherever
      * possible so that the client won't send the Authorization header
@@ -1236,76 +1243,108 @@ static authn_status get_hash(request_rec
     return auth_result;
 }
 
-/* Check the nonce-count of a request against the count tracked for this
- * client, and update the tracked count.
+/* Check the nonce and nonce-count of a fully verified request against the
+ * state tracked for its client, record them, and generate a new challenge
+ * if they are not acceptable.
  *
- * 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.
+ * Both are compared against what the client last *used*, never against what
+ * was last issued to it: a nonce is issued whenever a challenge is
+ * generated, and anything quoting the client's opaque can provoke a
+ * challenge, so tracking what was issued lets an unauthenticated request
+ * invalidate the nonce which the legitimate client is holding.
+ *
+ * A one-time nonce (AuthDigestNonceLifetime 0) is therefore accepted iff it
+ * is newer than the last nonce this client used, which permits it exactly
+ * once. Otherwise, with AuthDigestNcCheck, a newer nonce starts a new count
+ * and the same nonce must raise it: the nonce-count is counted by the client
+ * per-nonce (RFC 7616 3.4.3). Within a nonce the count must strictly
+ * increase, but it need not increase by exactly one, since 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.
+ * it quotes.
  */
-static int check_and_record_nonce(const request_rec *r,
-                               const digest_header_rec *resp,
-                               const digest_config_rec *conf)
+static int check_and_record_nonce(request_rec *r, digest_header_rec *resp,
+                                  const digest_config_rec *conf)
 {
     client_entry *client = resp->client;
-    unsigned long nc, tracked;
+    unsigned long nc, tracked = 0;
     const char *snc = resp->nonce_count;
     char *endptr;
-    int accepted;
+    enum nonce_state state = NONCE_STALE;
 
-    if (!conf->check_nc) {
-        return OK;
+    if (!conf->check_nc && conf->nonce_lifetime != 0) {
+        return OK;              /* nothing is tracked per-client */
     }
 
     nc = strtol(snc, &endptr, 16);
     if (endptr < (snc+strlen(snc)) && !apr_isspace(*endptr)) {
         ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01773)
                       "invalid nc %s received - not a number", snc);
-        return !OK;
+        note_digest_auth_failure(r, conf, resp, 0);
+        return HTTP_UNAUTHORIZED;
     }
 
-    if (!client) {
-        /* Without an opaque identifying the client there is nothing to
-         * check the nonce-count against. */
-        return !OK;
-    }
+    if (client) {
+        apr_global_mutex_lock(client_lock);
 
-    apr_global_mutex_lock(client_lock);
+        tracked = client->nonce_count;
+        if (conf->nonce_lifetime == 0) {
+            /* one-time nonce: usable until it has been used */
+            state = (resp->nonce_time > client->last_nonce_time)
+                    ? NONCE_ACCEPTED : NONCE_STALE;
+        }
+        else if (resp->nonce_time > client->last_nonce_time
+                 || (resp->nonce_time == client->last_nonce_time
+                     && nc > tracked)) {
+            state = NONCE_ACCEPTED;
+        }
+        else {
+            state = NONCE_BAD_COUNT;
+        }
 
-    tracked = client->nonce_count;
+        if (state == NONCE_ACCEPTED) {
+            client->last_nonce_time = resp->nonce_time;
+            client->nonce_count     = nc;
+        }
 
-    /* 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);
     }
 
-    apr_global_mutex_unlock(client_lock);
-
-    if (!accepted) {
+    if (!client) {
+        ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(10618)
+                      "client %lu is no longer known - sending new nonce",
+                      resp->opaque_num);
+    }
+    else if (state == NONCE_STALE) {
+        ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(01779)
+                      "user %s: one-time-nonce %s already used - sending "
+                      "new nonce", r->user, resp->nonce);
+    }
+    else if (state == NONCE_BAD_COUNT) {
         ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01774)
                       "Warning, possible replay attack: nonce-count check "
                       "failed: %lu is not above %lu for nonce %s", nc,
                       tracked, resp->nonce);
-        return !OK;
     }
 
-    return OK;
+    switch (state) {
+    case NONCE_ACCEPTED:
+        return OK;
+
+    case NONCE_STALE:
+        /* the credentials were good, so the client can silently retry with
+         * the nonce from this challenge */
+        note_digest_auth_failure(r, conf, resp, 1);
+        return HTTP_UNAUTHORIZED;
+
+    default:
+        note_digest_auth_failure(r, conf, resp, 0);
+        return HTTP_UNAUTHORIZED;
+    }
 }
 
 static int check_nonce(request_rec *r, digest_header_rec *resp,
@@ -1350,16 +1389,8 @@ static int check_nonce(request_rec *r, d
             return HTTP_UNAUTHORIZED;
         }
     }
-    else if (conf->nonce_lifetime == 0 && resp->client) {
-        if (memcmp(resp->client->last_nonce, resp->nonce, NONCE_LEN)) {
-            ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(01779)
-                          "user %s: one-time-nonce mismatch - sending "
-                          "new nonce", r->user);
-            note_digest_auth_failure(r, conf, resp, 1);
-            return HTTP_UNAUTHORIZED;
-        }
-    }
-    /* else (lifetime < 0) => never expires */
+    /* else (lifetime <= 0) => never expires by time; a one-time nonce is
+     * retired by use, in check_and_record_nonce() */
 
     return OK;
 }
@@ -1672,12 +1703,7 @@ static int authenticate_digest_user(requ
         return res;
     }
 
-    if (check_and_record_nonce(r, resp, conf) != OK) {
-        note_digest_auth_failure(r, conf, resp, 0);
-        return HTTP_UNAUTHORIZED;
-    }
-
-    return OK;
+    return check_and_record_nonce(r, resp, conf);
 }
 
 /* Authentication-Info header code. */
@@ -1716,7 +1742,6 @@ static int add_auth_info(request_rec *r)
         const char *nonce = gen_nonce(r->pool, 0, resp->opaque, r->server,
                                       conf, ap_auth_name(r));
         nextnonce = apr_pstrcat(r->pool, ", nextnonce=\"", nonce, "\"", NULL);
-        memcpy(resp->client->last_nonce, nonce, NONCE_LEN+1);
     }
     /* else nonce never expires, hence no nextnonce */
 

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

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

Added: httpd/httpd/trunk/test/modules/aaa/test_008_onetime_nccheck.py
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_008_onetime_nccheck.py	Thu Aug 13 15:15:19 2026	(r1937105)
@@ -0,0 +1,185 @@
+"""One-time nonces (AuthDigestNonceLifetime 0), alone and with AuthDigestNcCheck.
+
+With a lifetime of 0 the server hands the client a nextnonce on every
+successful response, and a nonce may be used once: it is accepted only if
+it is newer than the last nonce that client used. The client counts from 1
+again for each new nonce, so with AuthDigestNcCheck also on, every request
+legitimately carries nc=00000001.
+
+The security property here is the one from test_007_replay.py, applied to
+the other piece of per-client state:
+
+    A request which fails to authenticate MUST NOT invalidate the nonce
+    which the legitimate client is holding.
+
+It did, when the client's state was the last nonce *issued* to it:
+note_digest_auth_failure() generates a fresh nonce and recorded it there,
+and any request quoting the client's opaque can provoke a challenge. So an
+eavesdropper who had captured one Authorization header could replay it at
+will -- the replay itself was correctly rejected, but it moved the stored
+nonce on, and the victim's next request was then refused. The opaque is in
+the clear in every challenge and every request, and such a captured header
+never goes stale for this purpose, since it works by failing.
+
+This needed no credentials and, despite where it was first noticed, no
+AuthDigestNcCheck: the tests below run against both locations to pin that
+the defect was in the one-time-nonce path, not in the combination.
+
+The state is now the last nonce the client actually *used*, which nothing
+unauthenticated can move.
+"""
+
+import pytest
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+BOTH = ["onetime", "onetime-nccheck"]
+
+NC_FAILED = "AH01774"
+NONCE_HASH_INVALID = "AH01776"
+PASSWORD_MISMATCH = "AH01794"
+
+
+class TestOneTimeNonce:
+
+    def url(self, env, location):
+        return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt")
+
+    def challenge(self, env, location):
+        r = env.curl_get(self.url(env, location))
+        assert r.response["status"] == 401
+        challenge = dc.DigestChallenge.parse(
+            r.response["header"]["www-authenticate"])
+        assert challenge.opaque is not None, \
+            "one-time nonces are tracked per client, so an opaque is required"
+        return challenge
+
+    def header(self, location, challenge, nc="00000001", cnonce="onetime-cnonce",
+               response=None):
+        """A correct Authorization header, unless response= overrides the
+        digest -- an attacker can build that from an observed request
+        without knowing the password."""
+        return dc.build_authorization(
+            AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+            method="GET", uri=f"/digest/{location}/secret.txt", nc=nc,
+            cnonce=cnonce, response=response)
+
+    def send(self, env, location, auth):
+        return env.curl_get(self.url(env, location),
+                            options=["-H", f"Authorization: {auth}"])
+
+    def follow_nextnonce(self, r, challenge):
+        """Advance the client to the nextnonce it was just handed."""
+        ai = dc.parse_params(r.response["header"]["authentication-info"])
+        assert "nextnonce" in ai
+        assert ai["nextnonce"] != challenge.nonce
+        challenge.nonce = ai["nextnonce"]
+
+    def test_digest_080_nccheck_does_not_break_the_onetime_chain(self, env):
+        # Each nonce is new, so the client's count restarts at 1 every time
+        # and the nonce-count check must not object. (Before the nonce-count
+        # was tracked per-nonce this alternated 200, 401, 200, 401, ...)
+        challenge = self.challenge(env, "onetime-nccheck")
+        for _ in range(4):
+            r = self.send(env, "onetime-nccheck", self.header(
+                "onetime-nccheck", challenge, nc="00000001"))
+            assert r.response["status"] == 200
+            self.follow_nextnonce(r, challenge)
+
+    @pytest.mark.parametrize("location", BOTH)
+    def test_digest_081_onetime_nonce_rejects_immediate_replay(self, env, location):
+        challenge = self.challenge(env, location)
+        captured = self.header(location, challenge)
+        assert self.send(env, location, captured).response["status"] == 200
+
+        r = self.send(env, location, captured)
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+        assert r.response["status"] == 401
+        assert dc.DigestChallenge.parse(
+            r.response["header"]["www-authenticate"]).stale is True
+
+    @pytest.mark.parametrize("location", BOTH)
+    def test_digest_082_onetime_nonce_rejects_replay_after_rotation(self, env, location):
+        # The captured header stays rejected once the client has moved on
+        # through the nextnonce chain.
+        challenge = self.challenge(env, location)
+        captured = self.header(location, challenge)
+        r = self.send(env, location, captured)
+        assert r.response["status"] == 200
+        self.follow_nextnonce(r, challenge)
+
+        r = self.send(env, location, self.header(location, challenge))
+        assert r.response["status"] == 200
+        self.follow_nextnonce(r, challenge)
+
+        r = self.send(env, location, captured)
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+        assert r.response["status"] == 401
+
+    @pytest.mark.parametrize("location", BOTH)
+    def test_digest_083_replay_does_not_invalidate_the_clients_nonce(self, env, location):
+        # The eavesdropper's version: no credentials, no forgery, just one
+        # captured Authorization header replayed after the client has moved
+        # on. Rejecting it is correct; denying the client's next request is
+        # not.
+        challenge = self.challenge(env, location)
+        captured = self.header(location, challenge)
+        r = self.send(env, location, captured)
+        assert r.response["status"] == 200
+        self.follow_nextnonce(r, challenge)
+
+        replay_status = self.send(env, location, captured).response["status"]
+
+        r = self.send(env, location, self.header(location, challenge))
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+        assert replay_status == 401
+        assert r.response["status"] == 200, \
+            "the replay moved the client's one-time nonce on and locked it out"
+
+    @pytest.mark.parametrize("location", BOTH)
+    def test_digest_084_bogus_request_does_not_invalidate_the_clients_nonce(
+            self, env, location):
+        # Same property with a forged digest rather than a captured one, so
+        # it holds however the attacker's request comes to fail.
+        challenge = self.challenge(env, location)
+        r = self.send(env, location, self.header(location, challenge))
+        assert r.response["status"] == 200
+        self.follow_nextnonce(r, challenge)
+
+        bogus = self.header(location, challenge, cnonce="bogus",
+                            response="0" * 32)
+        bogus_status = self.send(env, location, bogus).response["status"]
+
+        r = self.send(env, location, self.header(location, challenge))
+        env.httpd_error_log.ignore_recent(
+            lognos=[NC_FAILED, NONCE_HASH_INVALID, PASSWORD_MISMATCH])
+        assert bogus_status == 401
+        assert r.response["status"] == 200, \
+            "the bogus request moved the client's one-time nonce on and locked it out"
+
+    @pytest.mark.parametrize("location", BOTH)
+    def test_digest_085_replay_rejected_when_the_client_entry_is_gone(self, env,
+                                                                      location):
+        # The client table is small -- AuthDigestShmemSize defaults to 1000
+        # bytes, "~ 12 entries" -- and a request with no credentials at all
+        # allocates an entry, since the challenge it gets back has to carry an
+        # opaque. An attacker can therefore make gc() discard a client's entry
+        # for the price of a dozen bare requests.
+        #
+        # A captured request must still not be replayable once that has
+        # happened. It used to be: check_nonce() skipped the one-time
+        # comparison entirely when the client was unknown, so the nonce was
+        # taken on trust and the replay served the protected resource.
+        challenge = self.challenge(env, location)
+        captured = self.header(location, challenge)
+        assert self.send(env, location, captured).response["status"] == 200
+        assert self.send(env, location, captured).response["status"] == 401
+
+        for _ in range(40):
+            env.curl_get(self.url(env, location))
+
+        r = self.send(env, location, captured)
+        env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+        assert r.response["status"] == 401, \
+            "captured request replayed once the client entry was evicted"