diff --git a/README.md b/README.md index 096fe5c..b6829bc 100644 --- a/README.md +++ b/README.md @@ -2415,6 +2415,7 @@ Developed with ❤️ for the HAProxy community ## Release Notes +- **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. - **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. - **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.`) 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. - **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. diff --git a/backend/main.py b/backend/main.py index 03777a7..ecaba8f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,7 +9,7 @@ import asyncio from datetime import datetime, timedelta # Build/deploy marker for the v1.8.x (Issue #35, DNS-01) rollout — ensures the pipeline ships this commit's image. -_version_info = {"version": "1.8.1", "releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)", "releaseDate": "2026-06-24"} +_version_info = {"version": "1.8.2", "releaseName": "ACME nonce fix (ZeroSSL registration)", "releaseDate": "2026-06-25"} for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]: try: with open(_vpath) as _vf: diff --git a/backend/services/acme_service.py b/backend/services/acme_service.py index 71ddf7b..08b1f2f 100644 --- a/backend/services/acme_service.py +++ b/backend/services/acme_service.py @@ -28,7 +28,7 @@ def _b64url(data: bytes) -> str: def _b64url_decode(s: str) -> bytes: - s += '=' * (4 - len(s) % 4) + s += '=' * (-len(s) % 4) # pad to a multiple of 4 (0 pad when already aligned) return base64.urlsafe_b64decode(s) @@ -37,7 +37,11 @@ class ACMEService: def __init__(self): self._directory_cache: Dict[str, dict] = {} - self._nonce: Optional[str] = None + # Anti-replay nonces are scoped PER CA (directory_url). A Replay-Nonce issued by one ACME + # server must never be sent in a JWS to another, or the second server rejects it (e.g. ZeroSSL + # "malformed: The Replay Nonce could not be base64url-decoded"). This client is a process-wide + # singleton shared across CAs, so a single shared nonce was leaking across them. + self._nonce_by_dir: Dict[str, str] = {} async def _get_settings(self) -> dict: conn = await get_database_connection() @@ -71,17 +75,21 @@ class ACMEService: raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}") data = await resp.json() if 'Replay-Nonce' in resp.headers: - self._nonce = resp.headers['Replay-Nonce'] + self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce'] data['_fetched_at'] = time.time() self._directory_cache[directory_url] = data return data async def _get_nonce(self, directory_url: str) -> str: - if self._nonce: - nonce = self._nonce - self._nonce = None - return nonce + # Use a cached nonce for THIS CA only; otherwise fetch a fresh one from THIS CA's newNonce. + cached = self._nonce_by_dir.pop(directory_url, None) + if cached: + return cached directory = await self.get_directory(directory_url) + # get_directory may have just captured a nonce for this CA from the directory response. + cached = self._nonce_by_dir.pop(directory_url, None) + if cached: + return cached async with aiohttp.ClientSession() as session: async with session.head(directory['newNonce']) as resp: return resp.headers['Replay-Nonce'] @@ -188,11 +196,17 @@ class ACMEService: timeout=aiohttp.ClientTimeout(total=30), ) as resp: if 'Replay-Nonce' in resp.headers: - self._nonce = resp.headers['Replay-Nonce'] + self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce'] - if resp.status == 400: + if resp.status == 400 and attempt < 2: err = await resp.json() - if err.get('type') == 'urn:ietf:params:acme:error:badNonce' and attempt < 2: + etype = (err.get('type') or '') + edetail = (err.get('detail') or '').lower() + # Retry on badNonce, and on any nonce-related malformed rejection (e.g. + # "The Replay Nonce could not be base64url-decoded") — refetch a FRESH nonce + # from the target CA and resign. With per-CA scoping the cross-CA cause is gone; + # this is defense-in-depth so a stale/rejected nonce always self-heals. + if etype.endswith('badNonce') or 'nonce' in edetail: nonce = resp.headers.get('Replay-Nonce') or await self._get_nonce(directory_url) protected['nonce'] = nonce body = self._sign_jws(private_key, protected, payload) diff --git a/backend/tests/test_dns01.py b/backend/tests/test_dns01.py index c0c1148..cabe871 100644 --- a/backend/tests/test_dns01.py +++ b/backend/tests/test_dns01.py @@ -89,3 +89,24 @@ def test_cloudflare_token_sanitize(): p = CloudflareDNSProvider({"api_token": '"my-token_123"'}) assert p._token == 'my-token_123' assert p._raw_token == '"my-token_123"' + + +def test_b64url_decode_padding_roundtrip(): + # Issue #35 v1.8.2: _b64url_decode must round-trip for EVERY length, including base64url strings + # whose length is a multiple of 4 (the case the old padding formula '=' * (4 - len%4) over-padded). + from services.acme_service import _b64url as enc_fn, _b64url_decode as dec_fn + for n in range(0, 20): + data = bytes(range(n)) + assert dec_fn(enc_fn(data)) == data, f"round-trip failed at byte length {n}" + + +def test_nonce_scoped_per_directory(): + # Issue #35 v1.8.2: a nonce cached for one CA (directory_url) must never be returned for another, + # and must be single-use. Both directories are pre-cached so _get_nonce returns without network. + import asyncio + svc = ACMEService() + svc._nonce_by_dir = {"https://a.example/dir": "NONCE_A", "https://b.example/dir": "NONCE_B"} + got = asyncio.run(svc._get_nonce("https://a.example/dir")) + assert got == "NONCE_A" # returns THIS CA's nonce + assert svc._nonce_by_dir.get("https://a.example/dir") is None # consumed (single-use) + assert svc._nonce_by_dir.get("https://b.example/dir") == "NONCE_B" # the other CA is untouched diff --git a/frontend/package.json b/frontend/package.json index bee03a9..c523888 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.8.1", + "version": "1.8.2", "description": "HAProxy Load Balancer Management UI", "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/version.json b/version.json index ef54cf4..20e2442 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.8.1", - "releaseName": "ACME DNS-01 fixes (Cloudflare token, EAB, Apply Management)", - "releaseDate": "2026-06-24" + "version": "1.8.2", + "releaseName": "ACME nonce fix (ZeroSSL registration)", + "releaseDate": "2026-06-25" }