Skip to content

Commit e86e86a

Browse files
fix(acme): scope ACME nonce per CA - fixes ZeroSSL registration (v1.8.2)
ZeroSSL/Google account registration failed with `malformed: The Replay Nonce could not be base64url-decoded`: the ACME client (a process-wide singleton) kept a single anti-replay nonce shared across certificate authorities, so a nonce issued by one CA could be sent to another, and the auto-retry only covered `badNonce`. - Scope the nonce per CA (self._nonce_by_dir keyed by directory_url): a nonce from one CA is never sent to another; account registration always uses a fresh nonce from the target CA. - Broaden the 400 retry to also recover from the nonce-malformed rejection. - Fix _b64url_decode padding (used for the EAB HMAC key). Backend-only; HTTP-01 and Let's Encrypt are unaffected. Addresses #35.
1 parent 70ebc02 commit e86e86a

6 files changed

Lines changed: 51 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2415,6 +2415,7 @@ Developed with ❤️ for the HAProxy community
24152415

24162416
## Release Notes
24172417

2418+
- **v1.8.2** (2026-06-25) — **ACME nonce fix** (Issue #35 follow-up): the ACME client now scopes the anti-replay nonce **per certificate authority** so a nonce issued by one CA is never sent to another. This fixes ZeroSSL/Google account registration failing with `malformed: The Replay Nonce could not be base64url-decoded` (the client previously shared one nonce across CAs and only auto-retried on `badNonce`). Account registration now always uses a fresh nonce from the target CA, and the retry covers this case too. Backend-only; HTTP-01 and Let's Encrypt are unaffected.
24182419
- **v1.8.1** (2026-06-24) — **ACME DNS-01 fixes** (Issue #35 follow-up): Cloudflare API tokens are now sanitized so a pasted token with quotes/spaces no longer fails with "Invalid request headers"; ZeroSSL/Google **External Account Binding (EAB)** can be entered per-account in the register dialog and EAB-required failures show a clear message; and **Apply Management** now categorizes cluster ACME enable/disable changes under their own "ACME Challenge Routing" section and **Apply/Reject All** correctly process them (previously "Rejected 0 HA/VIP change(s)"), consistent with every other entity. Fully backward compatible.
24192420
- **v1.8.0** (2026-06-23) — **ACME DNS-01 challenge support** (Issue #35): Auto SSL can now validate via a **DNS TXT record** (`_acme-challenge.<domain>`) instead of HTTP-01 on port 80, enabling certificates for **internal/isolated clusters with no public ingress** and **wildcard** certificates (`*.example.com`). Pluggable **per-account DNS provider** (Manual + Cloudflare to start; credentials verified on save and **encrypted at rest**, never returned by the API or logged), the same **PENDING → APPLIED** pipeline, a **bounded automatic retry** on propagation lag, and a **DNS-01 event timeline** in the order detail. **Opt-in** via Settings → ACME (global switch, default off); **HTTP-01 is byte-for-byte unchanged**, with **zero agent or rendered-config changes**. Manual DNS-01 certificates cannot auto-renew unattended; the UI states this and disables auto-renew for them.
24202421
- **v1.7.8** (2026-06-07) — HA / VIP apply progress now shows **per-node** convergence: a multi-node VIP's apply popup reads "Syncing HA/VIP… 1/2 node(s) converged" (matching the HA/VIP table) instead of a coarse per-change count. Frontend-only.

backend/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from datetime import datetime, timedelta
1010

1111
# Build/deploy marker for the v1.8.x (Issue #35, DNS-01) rollout — ensures the pipeline ships this commit's image.
12-
_version_info = {"version": "1.8.1", "releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)", "releaseDate": "2026-06-24"}
12+
_version_info = {"version": "1.8.2", "releaseName": "ACME nonce fix (ZeroSSL registration)", "releaseDate": "2026-06-25"}
1313
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
1414
try:
1515
with open(_vpath) as _vf:

backend/services/acme_service.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def _b64url(data: bytes) -> str:
2828

2929

3030
def _b64url_decode(s: str) -> bytes:
31-
s += '=' * (4 - len(s) % 4)
31+
s += '=' * (-len(s) % 4) # pad to a multiple of 4 (0 pad when already aligned)
3232
return base64.urlsafe_b64decode(s)
3333

3434

@@ -37,7 +37,11 @@ class ACMEService:
3737

3838
def __init__(self):
3939
self._directory_cache: Dict[str, dict] = {}
40-
self._nonce: Optional[str] = None
40+
# Anti-replay nonces are scoped PER CA (directory_url). A Replay-Nonce issued by one ACME
41+
# server must never be sent in a JWS to another, or the second server rejects it (e.g. ZeroSSL
42+
# "malformed: The Replay Nonce could not be base64url-decoded"). This client is a process-wide
43+
# singleton shared across CAs, so a single shared nonce was leaking across them.
44+
self._nonce_by_dir: Dict[str, str] = {}
4145

4246
async def _get_settings(self) -> dict:
4347
conn = await get_database_connection()
@@ -71,17 +75,21 @@ async def get_directory(self, directory_url: str) -> dict:
7175
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
7276
data = await resp.json()
7377
if 'Replay-Nonce' in resp.headers:
74-
self._nonce = resp.headers['Replay-Nonce']
78+
self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
7579
data['_fetched_at'] = time.time()
7680
self._directory_cache[directory_url] = data
7781
return data
7882

7983
async def _get_nonce(self, directory_url: str) -> str:
80-
if self._nonce:
81-
nonce = self._nonce
82-
self._nonce = None
83-
return nonce
84+
# Use a cached nonce for THIS CA only; otherwise fetch a fresh one from THIS CA's newNonce.
85+
cached = self._nonce_by_dir.pop(directory_url, None)
86+
if cached:
87+
return cached
8488
directory = await self.get_directory(directory_url)
89+
# get_directory may have just captured a nonce for this CA from the directory response.
90+
cached = self._nonce_by_dir.pop(directory_url, None)
91+
if cached:
92+
return cached
8593
async with aiohttp.ClientSession() as session:
8694
async with session.head(directory['newNonce']) as resp:
8795
return resp.headers['Replay-Nonce']
@@ -188,11 +196,17 @@ async def _signed_request(
188196
timeout=aiohttp.ClientTimeout(total=30),
189197
) as resp:
190198
if 'Replay-Nonce' in resp.headers:
191-
self._nonce = resp.headers['Replay-Nonce']
199+
self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
192200

193-
if resp.status == 400:
201+
if resp.status == 400 and attempt < 2:
194202
err = await resp.json()
195-
if err.get('type') == 'urn:ietf:params:acme:error:badNonce' and attempt < 2:
203+
etype = (err.get('type') or '')
204+
edetail = (err.get('detail') or '').lower()
205+
# Retry on badNonce, and on any nonce-related malformed rejection (e.g.
206+
# "The Replay Nonce could not be base64url-decoded") — refetch a FRESH nonce
207+
# from the target CA and resign. With per-CA scoping the cross-CA cause is gone;
208+
# this is defense-in-depth so a stale/rejected nonce always self-heals.
209+
if etype.endswith('badNonce') or 'nonce' in edetail:
196210
nonce = resp.headers.get('Replay-Nonce') or await self._get_nonce(directory_url)
197211
protected['nonce'] = nonce
198212
body = self._sign_jws(private_key, protected, payload)

backend/tests/test_dns01.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,24 @@ def test_cloudflare_token_sanitize():
8989
p = CloudflareDNSProvider({"api_token": '"my-token_123"'})
9090
assert p._token == 'my-token_123'
9191
assert p._raw_token == '"my-token_123"'
92+
93+
94+
def test_b64url_decode_padding_roundtrip():
95+
# Issue #35 v1.8.2: _b64url_decode must round-trip for EVERY length, including base64url strings
96+
# whose length is a multiple of 4 (the case the old padding formula '=' * (4 - len%4) over-padded).
97+
from services.acme_service import _b64url as enc_fn, _b64url_decode as dec_fn
98+
for n in range(0, 20):
99+
data = bytes(range(n))
100+
assert dec_fn(enc_fn(data)) == data, f"round-trip failed at byte length {n}"
101+
102+
103+
def test_nonce_scoped_per_directory():
104+
# Issue #35 v1.8.2: a nonce cached for one CA (directory_url) must never be returned for another,
105+
# and must be single-use. Both directories are pre-cached so _get_nonce returns without network.
106+
import asyncio
107+
svc = ACMEService()
108+
svc._nonce_by_dir = {"https://a.example/dir": "NONCE_A", "https://b.example/dir": "NONCE_B"}
109+
got = asyncio.run(svc._get_nonce("https://a.example/dir"))
110+
assert got == "NONCE_A" # returns THIS CA's nonce
111+
assert svc._nonce_by_dir.get("https://a.example/dir") is None # consumed (single-use)
112+
assert svc._nonce_by_dir.get("https://b.example/dir") == "NONCE_B" # the other CA is untouched

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "haproxy-openmanager-frontend",
3-
"version": "1.8.1",
3+
"version": "1.8.2",
44
"description": "HAProxy Load Balancer Management UI",
55
"license": "AGPL-3.0-or-later",
66
"dependencies": {

version.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "1.8.1",
3-
"releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)",
4-
"releaseDate": "2026-06-24"
2+
"version": "1.8.2",
3+
"releaseName": "ACME nonce fix (ZeroSSL registration)",
4+
"releaseDate": "2026-06-25"
55
}

0 commit comments

Comments
 (0)