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) <[email protected]>
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"
lmpx.com only provides a reader for public news (NNTP) servers. It is not
affiliated with the servers or forums shown here and is not responsible for
the content of articles, which is written by their respective authors.