mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-12 05:48:58 +00:00
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.
This commit is contained in:
@@ -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.<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.
|
||||
- **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.
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user