From 0c0adbb5fdabc542aa2f73e773de87454fe90d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 13:24:27 -0300 Subject: [PATCH 1/7] fix(core): require hybrid dbmldsa_sig on enforced licenses Ed25519-only tokens no longer verify in enforced mode, and a missing ML-DSA anchor fails closed. Reissue 1.7.4 licenses before an enforced 1.8 deployment. Refs #2042 --- CHANGELOG.md | 2 + core/licensing/guard.py | 33 ++++--- core/licensing/verify.py | 21 +++-- docs/LICENSE_FAQ.md | 2 +- docs/LICENSE_FAQ.pt_BR.md | 2 +- docs/LICENSING_SPEC.md | 8 +- docs/LICENSING_SPEC.pt_BR.md | 10 +-- docs/ops/QA_LOCAL_LICENSE.md | 6 +- docs/ops/QA_LOCAL_LICENSE.pt_BR.md | 6 +- tests/hybrid_license_fixture.py | 90 +++++++++++++++++++ tests/license_verify_pin.py | 6 ++ tests/test_api_assessment_poc.py | 4 +- tests/test_connector_tier_gating.py | 21 +++-- ..._license_jwt_hybrid_invalid_mldsa_claim.py | 28 ++++-- tests/test_fingerprint_binding_deployments.py | 4 +- tests/test_license_canonical_path_fallback.py | 4 +- tests/test_licensing.py | 11 ++- tests/test_licensing_embedded_pubkey.py | 35 +++++++- tests/test_licensing_enforcement_no_bypass.py | 4 +- .../test_licensing_feature_enforcement_mvp.py | 4 +- tests/test_licensing_trust_anchor.py | 44 +++++---- tests/test_worker_cap.py | 4 +- 22 files changed, 269 insertions(+), 80 deletions(-) create mode 100644 tests/hybrid_license_fixture.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6397b105..c7dd95bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Human-readable summary of user-facing changes. **Detailed release notes:** [docs Migration notes on the **`1.8.0-rc`** working line (no new semver cut): +- **Hybrid license required (#2042):** enforced mode rejects a token that has no `dbmldsa_sig` (`hybrid_claim_required`) and rejects verification when the packaged ML-DSA anchor is missing (`missing_mldsa_anchor`). Ed25519-only licenses from 1.7.4 must be reissued before an enforced 1.8 deployment. `licensing.mode: open` is unchanged. + - **FastAPI floor (#2089):** `fastapi>=0.143.0`. Native OpenTelemetry auto-config is opt-in (`FASTAPI_OTEL_AUTO_CONFIGURE=true` or `telemetry={"auto_configure": True}`). The app passes `telemetry={"auto_configure": False}` and still exports only when `DATA_BOAR_OTEL_ENABLED` is set. - **Credential env names (#2086):** `pass_from_env`, `password_from_env`, `user_from_env`, `token_from_env`, and `client_secret_from_env` fail at load when the name is outside the `DATA_BOAR_` / `REST_API_` / `API_` prefixes, or when the destination is missing from that target’s `allowed_hosts`. Shipped examples now use prefixed names (`DATA_BOAR_DEMO_DB_PASSWORD`, `DATA_BOAR_LAB_*`). - **`GET /health` (#2077):** the unauthenticated body is only `{"status":"ok"}`. License, trust, transport, and integrity moved to authenticated `GET /status`. diff --git a/core/licensing/guard.py b/core/licensing/guard.py index dddec351..03ebc810 100644 --- a/core/licensing/guard.py +++ b/core/licensing/guard.py @@ -31,7 +31,6 @@ from core.licensing.paths import resolve_license_path from core.licensing.verify import ( CLAIM_MLDSA_SIG, - decode_license_jwt, decode_license_jwt_hybrid, load_ed25519_public_key_pem, load_embedded_official_public_key_pem, @@ -45,9 +44,8 @@ def _token_has_mldsa_claim(token: str) -> bool: """True when the unverified JWT payload carries a ``dbmldsa_sig`` key at all. - Any present value (including ``null``, empty, or non-string) must take the - hybrid path so ``decode_license_jwt_hybrid`` fails closed; only an absent - key selects Ed25519-only verification. + Any present value (including ``null``, empty, or non-string) is a claim. + An absent key is not Ed25519-only: ``LicenseGuard`` rejects it. """ parts = token.split(".") if len(parts) != 3 or not parts[1]: @@ -398,13 +396,26 @@ def _evaluate(self) -> None: try: raw = Path(lic_path).read_text(encoding="utf-8").strip() - hybrid = _token_has_mldsa_claim(raw) - if hybrid: - claims = decode_license_jwt_hybrid( - raw, pub, self._load_mldsa_public_key() + if not _token_has_mldsa_claim(raw): + self._context = LicenseContext( + state="INVALID", + mode="enforced", + machine_fingerprint=mfp, + detail="hybrid_claim_required", + watermark="INVALID_TOKEN", + ) + return + mldsa_pub = self._load_mldsa_public_key() + if mldsa_pub is None: + self._context = LicenseContext( + state="INVALID", + mode="enforced", + machine_fingerprint=mfp, + detail="missing_mldsa_anchor", + watermark="INVALID_KEY_MATERIAL", ) - else: - claims = decode_license_jwt(raw, pub) + return + claims = decode_license_jwt_hybrid(raw, pub, mldsa_pub) except InvalidSignature: self._context = LicenseContext( state="INVALID", @@ -605,7 +616,7 @@ def _evaluate(self) -> None: machine_fingerprint=mfp, detail=_license_detail( state, - hybrid=hybrid, + hybrid=True, rotation_epoch=getattr(self, "_rotation_epoch", None), ), watermark=wm, diff --git a/core/licensing/verify.py b/core/licensing/verify.py index 02560341..98b3a626 100644 --- a/core/licensing/verify.py +++ b/core/licensing/verify.py @@ -21,7 +21,7 @@ # pyjwt[crypto] uses cryptography for EdDSA -# Optional hybrid PQC claim (license-studio #48); JOSE alg remains EdDSA. +# Required hybrid PQC claim (license-studio #48); JOSE alg remains EdDSA. CLAIM_MLDSA_SIG = "dbmldsa_sig" # Official issuer verify key shipped in the wheel (#1331). Public material only. @@ -137,21 +137,20 @@ def decode_license_jwt_hybrid( mldsa_pub: MLDSA65PublicKey | None = None, ) -> dict[str, Any]: """ - Verify EdDSA (``decode_license_jwt``) and optional ``dbmldsa_sig`` (ML-DSA-65). + Verify EdDSA and a required ``dbmldsa_sig`` (ML-DSA-65). - When the ``dbmldsa_sig`` claim is **absent**, only Ed25519 is verified. When the claim is - **present** (including JSON ``null`` or other invalid values), verification is fail-closed. - ``mldsa_pub`` is required for a present claim. The ML-DSA - message is ``header_b64`` + ``.`` + base64url(JSON payload without that claim), with - JSON matching license-studio ``pkg/verify/hybrid.go`` (sorted keys, Go HTML escapes). + A token without ``dbmldsa_sig`` is invalid. A present claim (including JSON + ``null`` or other invalid values) is fail-closed. ``mldsa_pub`` is required. + The ML-DSA message is ``header_b64`` + ``.`` + base64url(JSON payload without + that claim), with JSON matching license-studio ``pkg/verify/hybrid.go`` + (sorted keys, Go HTML escapes). - The claim is optional: an attacker who forges Ed25519 can omit ``dbmldsa_sig`` and - ``decode_license_jwt`` still accepts the token — hybrid verify does not raise the forgery - bar until a future enforcement policy requires ML-DSA. + ``decode_license_jwt`` still checks only Ed25519 so callers can inspect a + token. Production ``LicenseGuard`` always uses this function. """ claims = decode_license_jwt(token, ed25519_pub) if CLAIM_MLDSA_SIG not in claims: - return claims + raise ValueError("dbmldsa_sig is required") raw = claims[CLAIM_MLDSA_SIG] if mldsa_pub is None: raise ValueError( diff --git a/docs/LICENSE_FAQ.md b/docs/LICENSE_FAQ.md index 3b92437a..b733a245 100644 --- a/docs/LICENSE_FAQ.md +++ b/docs/LICENSE_FAQ.md @@ -46,7 +46,7 @@ Yes. The BSD 3-Clause core is forkable under its terms. Two boundaries: **Layered defense, not code secrecy:** -1. **Signed Ed25519 JWT licenses** — fail-closed enforcement (`licensing.mode: enforced`). Optional library overlay (#48): `decode_license_jwt_hybrid` can also check `dbmldsa_sig` (ML-DSA-65). **`LicenseGuard` calls `decode_license_jwt_hybrid` when `dbmldsa_sig` is present** — see [LICENSING_SPEC.md](LICENSING_SPEC.md) *Hybrid Ed25519 + ML-DSA-65 overlay*. +1. **Signed Ed25519 + ML-DSA-65 JWT licenses** — fail-closed enforcement (`licensing.mode: enforced`). **`LicenseGuard` always calls `decode_license_jwt_hybrid`**. A token without `dbmldsa_sig` is invalid (#2042) — see [LICENSING_SPEC.md](LICENSING_SPEC.md) *Hybrid Ed25519 + ML-DSA-65 overlay*. 2. **Machine fingerprint binding** (`dbmfp`, deployment packs) — issuance-enforced deploy counts. 3. **Release integrity** — build digest verification and tamper-evident integrity anchor (TINTED/`-alpha` marking). 4. **Trademark and contract** — legal layer for commercial misuse. diff --git a/docs/LICENSE_FAQ.pt_BR.md b/docs/LICENSE_FAQ.pt_BR.md index 01ea366d..77e3274a 100644 --- a/docs/LICENSE_FAQ.pt_BR.md +++ b/docs/LICENSE_FAQ.pt_BR.md @@ -46,7 +46,7 @@ Sim. O core BSD 3-Clause é forkável nos termos da licença. Duas fronteiras: **Defesa em camadas, não segredo de código:** -1. **Licenças JWT assinadas com Ed25519** — enforcement fail-closed (`licensing.mode: enforced`). Overlay opcional na biblioteca (#48): `decode_license_jwt_hybrid` também pode checar `dbmldsa_sig` (ML-DSA-65). **O `LicenseGuard` chama `decode_license_jwt_hybrid` quando `dbmldsa_sig` está presente** — veja [LICENSING_SPEC.pt_BR.md](LICENSING_SPEC.pt_BR.md) *Overlay híbrido Ed25519 + ML-DSA-65*. +1. **Licenças JWT assinadas com Ed25519 + ML-DSA-65** — enforcement fail-closed (`licensing.mode: enforced`). **O `LicenseGuard` sempre chama `decode_license_jwt_hybrid`**. Token sem `dbmldsa_sig` é inválido (#2042) — veja [LICENSING_SPEC.pt_BR.md](LICENSING_SPEC.pt_BR.md) *Overlay híbrido Ed25519 + ML-DSA-65*. 2. **Binding por fingerprint de máquina** (`dbmfp`, deployment packs) — contagem de deploys aplicada na emissão. 3. **Integridade de release** — verificação de digest de build e âncora de integridade com evidência de adulteração (marcação TINTED/`-alpha`). 4. **Marca e contrato** — camada jurídica para uso comercial indevido. diff --git a/docs/LICENSING_SPEC.md b/docs/LICENSING_SPEC.md index 5b67873d..a7434b42 100644 --- a/docs/LICENSING_SPEC.md +++ b/docs/LICENSING_SPEC.md @@ -54,7 +54,7 @@ The directory name is **`databoar`** (org), not the repo slug `data-boar`. `lice Enforced mode verifies the `.lic` against the Ed25519 **public** key shipped **inside the wheel** as `core/licensing/license-pub-v1.pem` (loaded via `importlib.resources`). A clean install with a valid machine-bound `.lic` does **not** need a pubkey path. -The ML-DSA-65 **rotation / hybrid** anchor ships as `core/licensing/license-mldsa-pub-v1.pem` (same packaging path as Ed25519). Canonical on-disk SHA-256 (LF bytes): `37faef8ff167cf01a0fc6a2bc8b7cfcac6bfe5651360051b1eb00a0a60fa85b8`. Hybrid `dbmldsa_sig` verify remains **optional** until the operator adopts a mandatory policy; tokens without that claim stay Ed25519-only. +The ML-DSA-65 **rotation / hybrid** anchor ships as `core/licensing/license-mldsa-pub-v1.pem` (same packaging path as Ed25519). Canonical on-disk SHA-256 (LF bytes): `37faef8ff167cf01a0fc6a2bc8b7cfcac6bfe5651360051b1eb00a0a60fa85b8`. Hybrid `dbmldsa_sig` is **required** on every enforced token (#2042). A token without that claim is invalid. Reissue Ed25519-only licenses from 1.7.4 before upgrading a deployment that runs `licensing.mode: enforced`. Key rotation is a JSON attestation at `licensing.rotation_attestation_path`. It is accepted only when **both** embedded anchors (Ed25519 and ML-DSA-65) sign the same message and `epoch` is `>= MIN_ACCEPTED_KEY_EPOCH` in `core/licensing/trust_anchor.py`. The signed bytes are `data-boar/license-key-rotation/v1\\0` plus the epoch as an 8-byte big-endian integer plus the canonical JSON of the new public keys. The epoch is inside that message; changing it after signing fails the signature. One anchor, a raw PEM, or an older epoch fails closed (`rotation_rejected:…`). `missing_public_key` remains only when the packaged Ed25519 resource is absent and no accepted rotation is configured. @@ -114,17 +114,17 @@ JOSE `alg` stays **EdDSA** (Ed25519). License Studio can attach claim `dbmldsa_s | ------- | ------------ | | `decode_license_jwt` | Verifies EdDSA only. Extra `dbmldsa_sig` is ignored (retrocompat). | | `decode_license_jwt_hybrid` | EdDSA first, then ML-DSA when the claim is present. | -| `LicenseGuard` | Calls **`decode_license_jwt_hybrid`** when `dbmldsa_sig` is present. The ML-DSA key is the packaged anchor or an accepted rotation, never an env/YAML override (`untrusted_key_override`). Tokens without the claim stay Ed25519-only. A verified hybrid token sets `license_detail=hybrid_mldsa65_verified`. | +| `LicenseGuard` | Always calls **`decode_license_jwt_hybrid`**. The ML-DSA key is the packaged anchor or an accepted rotation, never an env/YAML override (`untrusted_key_override`). A missing claim is `hybrid_claim_required`. A missing anchor is `missing_mldsa_anchor`. A verified token sets `license_detail=hybrid_mldsa65_verified`. | **Constraints (verify against source, do not invent a runtime gate):** - Claim present + `mldsa_pub is None` → `ValueError` (`hybrid claim present: ML-DSA public key required`). -- "Present" means the `dbmldsa_sig` **key** exists in the payload. A present claim whose value is `null`, an empty string, or a non-string fails closed (`ValueError`; `LicenseGuard` → `INVALID`). Only an absent key selects the Ed25519-only path (#1996). +- "Present" means the `dbmldsa_sig` **key** exists in the payload. A present claim whose value is `null`, an empty string, or a non-string fails closed (`ValueError`; `LicenseGuard` → `INVALID`). An absent key is also invalid (`hybrid_claim_required`, #2042). #1996 still covers the present-but-invalid values. - Bad ML-DSA signature → `cryptography.exceptions.InvalidSignature`. - Payload JSON must match license-studio `pkg/verify/hybrid.go`: `json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False)` plus Go HTML-safe escapes (`<` `>` `&` U+2028 U+2029). - Public key PEM is `-----BEGIN ML-DSA-65 PUBLIC KEY-----` with **raw** public bytes (`MLDSA65PublicKey.from_public_bytes`), **not** PKIX/`SubjectPublicKeyInfo`. - Tests: `tests/test_licensing_hybrid_mldsa_verify.py` (skipped when OpenSSL has no ML-DSA: `backend.mldsa_supported()`). -- **Forgery bar:** an attacker who forges Ed25519 can **omit** `dbmldsa_sig` and the Ed25519-only path still accepts the token. Hybrid verify does not raise production enforcement until a future policy requires ML-DSA (ADR-0063 post-quantum watch). +- **Forgery bar:** omitting `dbmldsa_sig` does not fall back to Ed25519-only. Both signatures are required (#2042). **Multi-site verification (honest boundary, #718 + #846):** Implemented via option (1) — **array** of allowed fingerprints in the `dbmfp` claim (deployment pack). Runtime validates only that **its own** `compute_machine_fingerprint()` (hostname + optional `DATA_BOAR_MACHINE_SEED`) is in the pack — the **global** deploy count is enforced at **issuance** (the issuer signs a pack of at most `dbmax_deployments` fingerprints). Alternatives kept for reference: (2) **online** registration (privacy + ops cost); (3) **multiple JWTs** same `dbcid`, one fingerprint each. See [LICENSING_OPEN_CORE_AND_COMMERCIAL.md](LICENSING_OPEN_CORE_AND_COMMERCIAL.md) §Deployments, copies, and sites. diff --git a/docs/LICENSING_SPEC.pt_BR.md b/docs/LICENSING_SPEC.pt_BR.md index 98d20fe2..1c6db306 100644 --- a/docs/LICENSING_SPEC.pt_BR.md +++ b/docs/LICENSING_SPEC.pt_BR.md @@ -58,7 +58,7 @@ O nome do diretório é **`databoar`** (org), não o slug do repositório `data- O modo enforced verifica o `.lic` contra a chave **pública** Ed25519 que vai **dentro do wheel** em `core/licensing/license-pub-v1.pem` (carregada via `importlib.resources`). Uma instalação limpa com um `.lic` válido e vinculado à máquina **não** precisa de caminho de pubkey. -A âncora ML-DSA-65 de **rotação / híbrido** vai em `core/licensing/license-mldsa-pub-v1.pem` (mesmo empacotamento que a Ed25519). SHA-256 canônico do arquivo em disco (bytes LF): `37faef8ff167cf01a0fc6a2bc8b7cfcac6bfe5651360051b1eb00a0a60fa85b8`. A verificação híbrida `dbmldsa_sig` segue **opcional** até o operador adotar política obrigatória; tokens sem esse claim continuam só Ed25519. +A âncora ML-DSA-65 de **rotação / híbrido** vai em `core/licensing/license-mldsa-pub-v1.pem` (mesmo empacotamento que a Ed25519). SHA-256 canônico do arquivo em disco (bytes LF): `37faef8ff167cf01a0fc6a2bc8b7cfcac6bfe5651360051b1eb00a0a60fa85b8`. O claim `dbmldsa_sig` é **obrigatório** em todo token enforced (#2042). Token sem esse claim é inválido. Reemita licenças só-Ed25519 da 1.7.4 antes de atualizar um deployment com `licensing.mode: enforced`. A rotação de chave é uma atestação JSON em `licensing.rotation_attestation_path`. Ela só é aceita quando **as duas** âncoras embarcadas (Ed25519 e ML-DSA-65) assinam a mesma mensagem e `epoch` é `>= MIN_ACCEPTED_KEY_EPOCH` em `core/licensing/trust_anchor.py`. Os bytes assinados são `data-boar/license-key-rotation/v1\\0` mais a época como inteiro de 8 bytes big-endian mais o JSON canônico das chaves públicas novas. A época fica dentro dessa mensagem; mudá-la depois de assinar invalida a assinatura. Uma âncora só, um PEM cru ou uma época antiga falham fechado (`rotation_rejected:…`). `missing_public_key` só ocorre quando o recurso Ed25519 empacotado está ausente e não há rotação aceita. @@ -108,7 +108,7 @@ Claims customizados (com namespace para evitar colisões): | `dbmax_workers` | int | Máximo de workers de scan em paralelo (#551) — na prática, o número de **alvos varridos simultaneamente**. **Somente modo enforced:** o engine limita `scan.max_workers` a `min(scan.max_workers, dbmax_workers)`. Ausente/zero numa licença usável → aplicam-se os padrões do tier (Community **2** / Std **2** / Pro **4** / Pro+ **8** / Enterprise **ilimitado** / Partner **por contrato**; #2047). Pro+ é um tier (`Tier.PRO_PLUS`); um claim `dbmax_workers` positivo ainda vence o padrão do tier. O modo open nunca limita. O clamp é fail-soft e auditado (`workers_clamped`, WARNING). | | | `dbmax_deployments` | int | Máximo de **sites de produção licenciados** distintos (fingerprints) para este token (#846). **Enforced na emissão:** o emissor gera o pacote `dbmfp` com no máximo essa quantidade de entradas; o runtime lê o claim em `LicenseContext.max_deployments` (superfície de auditoria/relatório) e valida apenas o **próprio** fingerprint ∈ pacote. **0** pode significar **ilimitado** quando o contrato permite (Enterprise). Padrão Pro = **2** (ratificado pelo operador em 2026-06-11: on-prem + 1 cloud/branch) — ver `DEFAULT_PRO_DEPLOYMENTS` em `core/licensing/guard.py`. | | | `dbdeployment_pack_id` | string | ID de um **add-on comercial** (ex.: "+5 sites") para trilha de auditoria/refill (#846); exposto em `LicenseContext.deployment_pack_id`. Emissor: `scripts/issue_dev_license_jwt.py --dbmfp-pack [--pack-id ...]`. | | -| `dbmldsa_sig` | string | Overlay opcional ML-DSA-65 (#48). **Não** é algoritmo JOSE. Ver [Overlay híbrido Ed25519 + ML-DSA-65](#overlay-híbrido-ed25519--ml-dsa-65-48). | +| `dbmldsa_sig` | string | Claim híbrido ML-DSA-65 obrigatório no modo enforced (#2042). **Não** é algoritmo JOSE. Ver [Overlay híbrido Ed25519 + ML-DSA-65](#overlay-híbrido-ed25519--ml-dsa-65-48). | ### Overlay híbrido Ed25519 + ML-DSA-65 (#48) @@ -118,17 +118,17 @@ O `alg` JOSE permanece **EdDSA** (Ed25519). O License Studio pode anexar o claim | ---------- | ------------- | | `decode_license_jwt` | Verifica só EdDSA. `dbmldsa_sig` extra é ignorado (retrocompat). | | `decode_license_jwt_hybrid` | EdDSA primeiro; ML-DSA quando o claim está presente. | -| `LicenseGuard` | Chama **`decode_license_jwt_hybrid`** quando `dbmldsa_sig` está presente. A chave ML-DSA é a âncora empacotada ou uma rotação aceita, nunca um override por ambiente/YAML (`untrusted_key_override`). Token sem o claim continua só Ed25519. Token híbrido verificado define `license_detail=hybrid_mldsa65_verified`. | +| `LicenseGuard` | Sempre chama **`decode_license_jwt_hybrid`**. A chave ML-DSA é a âncora empacotada ou uma rotação aceita, nunca um override por ambiente/YAML (`untrusted_key_override`). Claim ausente é `hybrid_claim_required`. Âncora ausente é `missing_mldsa_anchor`. Token verificado define `license_detail=hybrid_mldsa65_verified`. | **Restrições (confira no código; não invente um gate de runtime):** - Claim presente + `mldsa_pub is None` → `ValueError` (`hybrid claim present: ML-DSA public key required`). -- "Presente" significa que a **chave** `dbmldsa_sig` existe no payload. Um claim presente com valor `null`, string vazia ou não-string falha fechado (`ValueError`; o `LicenseGuard` retorna `INVALID`). Só a chave ausente seleciona o caminho só-Ed25519 (#1996). +- "Presente" significa que a **chave** `dbmldsa_sig` existe no payload. Um claim presente com valor `null`, string vazia ou não-string falha fechado (`ValueError`; o `LicenseGuard` retorna `INVALID`). Chave ausente também é inválida (`hybrid_claim_required`, #2042). #1996 continua cobrindo o valor presente porém inválido. - Assinatura ML-DSA inválida → `cryptography.exceptions.InvalidSignature`. - O JSON do payload precisa bater com `pkg/verify/hybrid.go` do license-studio: `json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False)` mais os escapes HTML-safe do Go (`<` `>` `&` U+2028 U+2029). - O PEM público é `-----BEGIN ML-DSA-65 PUBLIC KEY-----` com bytes **brutos** (`MLDSA65PublicKey.from_public_bytes`), **não** PKIX/`SubjectPublicKeyInfo`. - Testes: `tests/test_licensing_hybrid_mldsa_verify.py` (pula se o OpenSSL não tiver ML-DSA: `backend.mldsa_supported()`). -- **Barra de forja:** quem forja Ed25519 pode **omitir** `dbmldsa_sig` e o caminho só-Ed25519 ainda aceita o token. A verificação híbrida **não** sobe o enforcement de produção até uma política futura exigir ML-DSA (watch pós-quântico do ADR-0063). +- **Barra de forja:** omitir `dbmldsa_sig` não cai no caminho só-Ed25519. As duas assinaturas são obrigatórias (#2042). **Verificação multi-site (fronteira honesta, #718 + #846):** Implementada via opção (1) — **array** de fingerprints permitidos no claim `dbmfp` (pacote de deployment). O runtime valida apenas que o **seu próprio** `compute_machine_fingerprint()` (hostname + `DATA_BOAR_MACHINE_SEED` opcional) está no pacote — a contagem **global** de deployments é enforced na **emissão** (o emissor assina um pacote com no máximo `dbmax_deployments` fingerprints); o runtime não consegue contar outros sites. Alternativas mantidas como referência: (2) registro **online** (custo de privacidade + operação); (3) **múltiplos JWTs** com o mesmo `dbcid`, um fingerprint cada. Ver [LICENSING_OPEN_CORE_AND_COMMERCIAL.md](LICENSING_OPEN_CORE_AND_COMMERCIAL.md) ([pt-BR](LICENSING_OPEN_CORE_AND_COMMERCIAL.pt_BR.md)) §Deployments, cópias e sites. diff --git a/docs/ops/QA_LOCAL_LICENSE.md b/docs/ops/QA_LOCAL_LICENSE.md index 7c157392..14927827 100644 --- a/docs/ops/QA_LOCAL_LICENSE.md +++ b/docs/ops/QA_LOCAL_LICENSE.md @@ -16,7 +16,7 @@ enforced mode is a real signed license. QA licenses are deliberately: Keep the Ed25519 **private** key outside Git (e.g. `~/.keys/data-boar/`). See `docs/private.example/licensing/README.md` for key generation. -Runtime `LicenseGuard` verifies EdDSA, and also ML-DSA-65 when the token carries `dbmldsa_sig` (`decode_license_jwt_hybrid`). That ML-DSA key is the packaged anchor or an accepted rotation. `DATA_BOAR_LICENSE_MLDSA_PUBLIC_KEY_*` fails closed (`untrusted_key_override`). Tokens without the claim stay Ed25519-only — see [LICENSING_SPEC.md](../LICENSING_SPEC.md). +Runtime `LicenseGuard` verifies EdDSA and ML-DSA-65 together (`decode_license_jwt_hybrid`). That ML-DSA key is the packaged anchor or an accepted rotation. `DATA_BOAR_LICENSE_MLDSA_PUBLIC_KEY_*` fails closed (`untrusted_key_override`). A token without `dbmldsa_sig` is invalid (`hybrid_claim_required`, #2042) — see [LICENSING_SPEC.md](../LICENSING_SPEC.md). ### Optional: encrypted signing key (passphrase) @@ -73,8 +73,8 @@ Send only the `.lic` file — never the private key. The official verify key is **embedded** in the install (`license-pub-v1.pem`). The ML-DSA-65 anchor (`license-mldsa-pub-v1.pem`, SHA-256 `37faef8ff167cf01a0fc6a2bc8b7cfcac6bfe5651360051b1eb00a0a60fa85b8`) ships in -the same wheel for rotation and optional hybrid verify; `dbmldsa_sig` is not -required yet. +the same wheel for rotation and required hybrid verify; `dbmldsa_sig` is +required on every enforced token (#2042). A valid machine-bound `.lic` is enough — no pubkey env/config on a clean install (#1331). A raw `DATA_BOAR_LICENSE_PUBLIC_KEY_*`, `DATA_BOAR_LICENSE_MLDSA_PUBLIC_KEY_*`, `licensing.public_key_path`, or `licensing.mldsa_public_key_path` fails closed diff --git a/docs/ops/QA_LOCAL_LICENSE.pt_BR.md b/docs/ops/QA_LOCAL_LICENSE.pt_BR.md index eb4a2ee4..2d85a8ed 100644 --- a/docs/ops/QA_LOCAL_LICENSE.pt_BR.md +++ b/docs/ops/QA_LOCAL_LICENSE.pt_BR.md @@ -18,7 +18,7 @@ deliberadamente: Mantenha a chave **privada** Ed25519 fora do Git (ex.: `~/.keys/data-boar/`). Veja `docs/private.example/licensing/README.md` para gerar as chaves. -O `LicenseGuard` em runtime verifica EdDSA e também ML-DSA-65 quando o token traz `dbmldsa_sig` (`decode_license_jwt_hybrid`). Essa chave ML-DSA é a âncora empacotada ou uma rotação aceita. `DATA_BOAR_LICENSE_MLDSA_PUBLIC_KEY_*` falha fechado (`untrusted_key_override`). Token sem o claim continua só Ed25519 — veja [LICENSING_SPEC.pt_BR.md](../LICENSING_SPEC.pt_BR.md). +O `LicenseGuard` em runtime verifica EdDSA e ML-DSA-65 juntos (`decode_license_jwt_hybrid`). Essa chave ML-DSA é a âncora empacotada ou uma rotação aceita. `DATA_BOAR_LICENSE_MLDSA_PUBLIC_KEY_*` falha fechado (`untrusted_key_override`). Token sem `dbmldsa_sig` é inválido (`hybrid_claim_required`, #2042) — veja [LICENSING_SPEC.pt_BR.md](../LICENSING_SPEC.pt_BR.md). ### Opcional: chave de assinatura cifrada (passphrase) @@ -75,8 +75,8 @@ Envie apenas o arquivo `.lic` — nunca a chave privada. A chave pública oficial de verificação já vai **embarcada** na instalação (`license-pub-v1.pem`). A âncora ML-DSA-65 (`license-mldsa-pub-v1.pem`, SHA-256 `37faef8ff167cf01a0fc6a2bc8b7cfcac6bfe5651360051b1eb00a0a60fa85b8`) vem no -mesmo wheel para rotação e verificação híbrida opcional; `dbmldsa_sig` ainda -não é obrigatório. Um `.lic` válido e vinculado à máquina basta — sem +mesmo wheel para rotação e verificação híbrida obrigatória; `dbmldsa_sig` é +obrigatório em todo token enforced (#2042). Um `.lic` válido e vinculado à máquina basta — sem env/config de pubkey numa instalação limpa (#1331). Um `DATA_BOAR_LICENSE_PUBLIC_KEY_*`, `DATA_BOAR_LICENSE_MLDSA_PUBLIC_KEY_*`, `licensing.public_key_path` ou `licensing.mldsa_public_key_path` falha diff --git a/tests/hybrid_license_fixture.py b/tests/hybrid_license_fixture.py new file mode 100644 index 00000000..fb90d187 --- /dev/null +++ b/tests/hybrid_license_fixture.py @@ -0,0 +1,90 @@ +"""Test-only ML-DSA key so enforced licenses can carry a required dbmldsa_sig. + +The private key is generated in the test process. It is never the org anchor +and never written into the repository. +""" + +from __future__ import annotations + +import base64 +import json + +import jwt +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA65PrivateKey + +from core.licensing.verify import CLAIM_MLDSA_SIG, _mldsa_payload_json_without_sig + +_TEST_MLDSA: MLDSA65PrivateKey | None = None + + +def test_mldsa_private() -> MLDSA65PrivateKey: + global _TEST_MLDSA + if _TEST_MLDSA is None: + _TEST_MLDSA = MLDSA65PrivateKey.generate() + return _TEST_MLDSA + + +def test_mldsa_public_pem() -> str: + raw = test_mldsa_private().public_key().public_bytes_raw() + body = base64.b64encode(raw).decode("ascii") + lines = [body[i : i + 64] for i in range(0, len(body), 64)] + return ( + "-----BEGIN ML-DSA-65 PUBLIC KEY-----\n" + + "\n".join(lines) + + "\n-----END ML-DSA-65 PUBLIC KEY-----\n" + ) + + +def pin_test_mldsa_anchor(pem: str | None = None) -> None: + import core.licensing.trust_anchor as trust_anchor + + pinned = pem if pem is not None else test_mldsa_public_pem() + trust_anchor.load_embedded_mldsa_anchor_pem = lambda: pinned + + +def seal_enforced_test_token( + ed_private: Ed25519PrivateKey, + token: str, + *, + mldsa_private: MLDSA65PrivateKey | None = None, + pin: bool = True, +) -> str: + """Return ``token`` with a valid test ``dbmldsa_sig``. + + ``pin=False`` leaves the ML-DSA loader alone (rotation tests already + installed the anchor the attestation must verify against). + """ + signer = mldsa_private or test_mldsa_private() + if pin: + if mldsa_private is None: + pin_test_mldsa_anchor() + else: + raw = signer.public_key().public_bytes_raw() + body = base64.b64encode(raw).decode("ascii") + lines = [body[i : i + 64] for i in range(0, len(body), 64)] + pin_test_mldsa_anchor( + "-----BEGIN ML-DSA-65 PUBLIC KEY-----\n" + + "\n".join(lines) + + "\n-----END ML-DSA-65 PUBLIC KEY-----\n" + ) + payload_b64 = token.split(".")[1] + pad = "=" * (-len(payload_b64) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload_b64 + pad)) + if not isinstance(claims, dict): + raise AssertionError("test token payload is not an object") + if CLAIM_MLDSA_SIG in claims: + return token + header_b64 = token.split(".", 1)[0] + payload_json = _mldsa_payload_json_without_sig(claims) + signed_payload_b64 = ( + base64.urlsafe_b64encode(payload_json.encode("utf-8")) + .decode("ascii") + .rstrip("=") + ) + sig = signer.sign(f"{header_b64}.{signed_payload_b64}".encode("utf-8")) + claims[CLAIM_MLDSA_SIG] = base64.urlsafe_b64encode(sig).decode("ascii").rstrip("=") + sealed = jwt.encode(claims, ed_private, algorithm="EdDSA") + if sealed.split(".", 1)[0] != header_b64: + raise AssertionError("JWT header changed after attaching dbmldsa_sig") + return sealed diff --git a/tests/license_verify_pin.py b/tests/license_verify_pin.py index 934ac9d2..4500ec69 100644 --- a/tests/license_verify_pin.py +++ b/tests/license_verify_pin.py @@ -7,6 +7,10 @@ from __future__ import annotations +import core.licensing.trust_anchor as _trust_anchor + +_ORIG_LOAD_EMBEDDED_MLDSA = _trust_anchor.load_embedded_mldsa_anchor_pem + def pin_embedded_ed25519_pem(pem: str) -> None: import core.licensing.guard as guard_mod @@ -16,8 +20,10 @@ def pin_embedded_ed25519_pem(pem: str) -> None: def restore_embedded_ed25519_loader() -> None: import core.licensing.guard as guard_mod + import core.licensing.trust_anchor as trust_anchor from core.licensing.verify import load_embedded_official_public_key_pem guard_mod.load_embedded_official_public_key_pem = ( load_embedded_official_public_key_pem ) + trust_anchor.load_embedded_mldsa_anchor_pem = _ORIG_LOAD_EMBEDDED_MLDSA diff --git a/tests/test_api_assessment_poc.py b/tests/test_api_assessment_poc.py index 7a86d6a8..35073ed7 100644 --- a/tests/test_api_assessment_poc.py +++ b/tests/test_api_assessment_poc.py @@ -52,7 +52,9 @@ def _make_license_token( } if extra: payload.update(extra) - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture diff --git a/tests/test_connector_tier_gating.py b/tests/test_connector_tier_gating.py index 17f41789..3fa4e50e 100644 --- a/tests/test_connector_tier_gating.py +++ b/tests/test_connector_tier_gating.py @@ -202,15 +202,20 @@ def _signed_license(tmp_path, dbtier: str) -> dict: .decode("ascii") ) now = datetime.now(timezone.utc) - token = jwt.encode( - { - "sub": "lic-gating", - "iat": int(now.timestamp()), - "exp": int((now + timedelta(days=1)).timestamp()), - "dbtier": dbtier, - }, + from tests.hybrid_license_fixture import seal_enforced_test_token + + token = seal_enforced_test_token( priv, - algorithm="EdDSA", + jwt.encode( + { + "sub": "lic-gating", + "iat": int(now.timestamp()), + "exp": int((now + timedelta(days=1)).timestamp()), + "dbtier": dbtier, + }, + priv, + algorithm="EdDSA", + ), ) lic = tmp_path / "t.lic" lic.write_text(token, encoding="utf-8") diff --git a/tests/test_decode_license_jwt_hybrid_invalid_mldsa_claim.py b/tests/test_decode_license_jwt_hybrid_invalid_mldsa_claim.py index 176dc0db..40a8c3ce 100644 --- a/tests/test_decode_license_jwt_hybrid_invalid_mldsa_claim.py +++ b/tests/test_decode_license_jwt_hybrid_invalid_mldsa_claim.py @@ -107,12 +107,11 @@ def _guard_for(tmp_path, monkeypatch, token: str, ed_pem: str, mldsa_pem: str): # --- library: decode_license_jwt_hybrid ------------------------------------- -def test_decode_license_jwt_hybrid_absent_mldsa_claim_ok(ed25519_pub) -> None: +def test_decode_license_jwt_hybrid_absent_mldsa_claim_rejected(ed25519_pub) -> None: ed_private, ed_pub = ed25519_pub token = _token_with_mldsa_claim(ed_private, _ABSENT) - claims = decode_license_jwt_hybrid(token, ed_pub, mldsa_pub=None) - assert CLAIM_MLDSA_SIG not in claims - assert claims["sub"] == "hybrid-claim-presence" + with pytest.raises(ValueError, match="dbmldsa_sig is required"): + decode_license_jwt_hybrid(token, ed_pub, mldsa_pub=None) def test_decode_license_jwt_hybrid_null_claim_requires_mldsa_key(ed25519_pub) -> None: @@ -148,13 +147,30 @@ def test_token_has_mldsa_claim_false_when_absent(ed25519_pub) -> None: assert not _token_has_mldsa_claim(_token_with_mldsa_claim(ed_private, _ABSENT)) -def test_license_guard_absent_mldsa_claim_is_valid( +def test_license_guard_absent_mldsa_claim_is_invalid( tmp_path, monkeypatch, ed25519_pub ) -> None: ed_private, _ = ed25519_pub token = _token_with_mldsa_claim(ed_private, _ABSENT) guard = _guard_for(tmp_path, monkeypatch, token, _ed_public_pem(ed_private), "") - assert guard.context.state == "VALID" + assert guard.context.state == "INVALID" + assert guard.context.detail == "hybrid_claim_required" + + +def test_license_guard_missing_mldsa_anchor_is_invalid( + tmp_path, monkeypatch, ed25519_pub +) -> None: + from tests.hybrid_license_fixture import seal_enforced_test_token + + ed_private, _ = ed25519_pub + token = seal_enforced_test_token( + ed_private, + _token_with_mldsa_claim(ed_private, _ABSENT), + pin=False, + ) + guard = _guard_for(tmp_path, monkeypatch, token, _ed_public_pem(ed_private), "") + assert guard.context.state == "INVALID" + assert guard.context.detail == "missing_mldsa_anchor" @pytest.mark.parametrize("bad_value", _INVALID_CLAIM_VALUES) diff --git a/tests/test_fingerprint_binding_deployments.py b/tests/test_fingerprint_binding_deployments.py index d5859208..c10dc4ad 100644 --- a/tests/test_fingerprint_binding_deployments.py +++ b/tests/test_fingerprint_binding_deployments.py @@ -86,7 +86,9 @@ def _make_token(priv: Ed25519PrivateKey, *, extra: dict | None = None) -> str: } if extra: payload.update(extra) - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture diff --git a/tests/test_license_canonical_path_fallback.py b/tests/test_license_canonical_path_fallback.py index 5464843f..d0c87af9 100644 --- a/tests/test_license_canonical_path_fallback.py +++ b/tests/test_license_canonical_path_fallback.py @@ -45,7 +45,9 @@ def _make_token(priv: Ed25519PrivateKey) -> str: "dbissuer": "test-issuer", "dbkid": "k1", } - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture diff --git a/tests/test_licensing.py b/tests/test_licensing.py index 8cc844d7..8ae49328 100644 --- a/tests/test_licensing.py +++ b/tests/test_licensing.py @@ -51,7 +51,9 @@ def _make_token( } if extra: payload.update(extra) - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture @@ -181,7 +183,12 @@ def test_enforced_malformed_exp_claim_fail_closed(ed25519_priv, tmp_path): "dbissuer": "test-issuer", "dbkid": "k1", } - tok = jwt.encode(payload, ed25519_priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + tok = seal_enforced_test_token( + ed25519_priv, + jwt.encode(payload, ed25519_priv, algorithm="EdDSA"), + ) lic = tmp_path / "bad-exp.lic" lic.write_text(tok, encoding="utf-8") cfg = {"licensing": {"mode": "enforced", "license_path": str(lic)}} diff --git a/tests/test_licensing_embedded_pubkey.py b/tests/test_licensing_embedded_pubkey.py index 56ef4607..6178c114 100644 --- a/tests/test_licensing_embedded_pubkey.py +++ b/tests/test_licensing_embedded_pubkey.py @@ -53,7 +53,9 @@ def _make_token( } if extra: payload.update(extra) - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture @@ -186,13 +188,20 @@ def _venv_python(venv: Path) -> Path: from importlib.resources import files from pathlib import Path +import base64 + import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA65PrivateKey from core.licensing.fingerprint import compute_machine_fingerprint from core.licensing.guard import LicenseGuard -from core.licensing.verify import load_embedded_official_public_key_pem +from core.licensing.verify import ( + CLAIM_MLDSA_SIG, + _mldsa_payload_json_without_sig, + load_embedded_official_public_key_pem, +) venv_root = Path(sys.argv[1]).resolve() lic_path = sys.argv[2] @@ -223,6 +232,28 @@ def _venv_python(venv: Path) -> Path: "dbmfp": compute_machine_fingerprint(), } token = jwt.encode(payload, priv, algorithm="EdDSA") +header_b64 = token.split(".", 1)[0] +payload_json = _mldsa_payload_json_without_sig(payload) +signed_payload_b64 = ( + base64.urlsafe_b64encode(payload_json.encode("utf-8")).decode("ascii").rstrip("=") +) +ml_priv = MLDSA65PrivateKey.generate() +sig = ml_priv.sign(f"{header_b64}.{signed_payload_b64}".encode("utf-8")) +payload[CLAIM_MLDSA_SIG] = base64.urlsafe_b64encode(sig).decode("ascii").rstrip("=") +token = jwt.encode(payload, priv, algorithm="EdDSA") +if token.split(".", 1)[0] != header_b64: + raise SystemExit("JWT header changed after attaching dbmldsa_sig") +raw_ml = ml_priv.public_key().public_bytes_raw() +body = base64.b64encode(raw_ml).decode("ascii") +lines = [body[i : i + 64] for i in range(0, len(body), 64)] +ml_pem = ( + "-----BEGIN ML-DSA-65 PUBLIC KEY-----\n" + + "\n".join(lines) + + "\n-----END ML-DSA-65 PUBLIC KEY-----\n" +) +import core.licensing.trust_anchor as trust_anchor + +trust_anchor.load_embedded_mldsa_anchor_pem = lambda: ml_pem Path(lic_path).write_text(token, encoding="utf-8") for k in ( diff --git a/tests/test_licensing_enforcement_no_bypass.py b/tests/test_licensing_enforcement_no_bypass.py index 52b6f68b..5e909ac5 100644 --- a/tests/test_licensing_enforcement_no_bypass.py +++ b/tests/test_licensing_enforcement_no_bypass.py @@ -63,7 +63,9 @@ def _make_token( } if extra: payload.update(extra) - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture diff --git a/tests/test_licensing_feature_enforcement_mvp.py b/tests/test_licensing_feature_enforcement_mvp.py index a892cbfc..89208470 100644 --- a/tests/test_licensing_feature_enforcement_mvp.py +++ b/tests/test_licensing_feature_enforcement_mvp.py @@ -47,7 +47,9 @@ def _make_token(priv: Ed25519PrivateKey, *, dbtier: str) -> str: "dbkid": "k1", "dbtier": dbtier, } - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture diff --git a/tests/test_licensing_trust_anchor.py b/tests/test_licensing_trust_anchor.py index ce6fd7e9..688572ba 100644 --- a/tests/test_licensing_trust_anchor.py +++ b/tests/test_licensing_trust_anchor.py @@ -72,23 +72,33 @@ def _attest( return json.dumps(doc) -def _license(priv: Ed25519PrivateKey) -> str: +def _license( + priv: Ed25519PrivateKey, + mldsa: MLDSA65PrivateKey | None = None, +) -> str: now = datetime.now(timezone.utc) - return jwt.encode( - { - "sub": "lic-rotation-1", - "iat": int(now.timestamp()), - "exp": int((now + timedelta(days=2)).timestamp()), - "dbcid": "lab-dev", - "dbcname": "Lab", - "dbenv": "qa", - "dbissuer": "test", - "dbkid": "rot", - "dbtier": "enterprise", - "dbmfp": compute_machine_fingerprint(), - }, + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token( priv, - algorithm="EdDSA", + jwt.encode( + { + "sub": "lic-rotation-1", + "iat": int(now.timestamp()), + "exp": int((now + timedelta(days=2)).timestamp()), + "dbcid": "lab-dev", + "dbcname": "Lab", + "dbenv": "qa", + "dbissuer": "test", + "dbkid": "rot", + "dbtier": "enterprise", + "dbmfp": compute_machine_fingerprint(), + }, + priv, + algorithm="EdDSA", + ), + mldsa_private=mldsa, + pin=mldsa is None, ) @@ -170,7 +180,7 @@ def test_unsigned_rotation_fails_and_cross_signed_rotation_passes( encoding="utf-8", ) lic = tmp_path / "rot.lic" - lic.write_text(_license(new_ed), encoding="utf-8") + lic.write_text(_license(new_ed, new_ml), encoding="utf-8") denied = LicenseGuard( { "licensing": { @@ -226,7 +236,7 @@ def test_unsigned_rotation_fails_and_cross_signed_rotation_passes( } ) assert accepted.context.state == "VALID" - assert accepted.context.detail == "rotation_epoch_1" + assert accepted.context.detail == "hybrid_mldsa65_verified;rotation_epoch_1" bare = canonical_rotation_message(1, new_ed_pem, new_ml_pem)[len(ROTATION_DOMAIN) :] no_domain = _attest( diff --git a/tests/test_worker_cap.py b/tests/test_worker_cap.py index 3f5a85cd..503cd0fd 100644 --- a/tests/test_worker_cap.py +++ b/tests/test_worker_cap.py @@ -67,7 +67,9 @@ def _make_token(priv: Ed25519PrivateKey, *, extra: dict | None = None) -> str: } if extra: payload.update(extra) - return jwt.encode(payload, priv, algorithm="EdDSA") + from tests.hybrid_license_fixture import seal_enforced_test_token + + return seal_enforced_test_token(priv, jwt.encode(payload, priv, algorithm="EdDSA")) @pytest.fixture From 98b6ad87c30cee2d513045daa2c999eeea41c93b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 13:31:27 -0300 Subject: [PATCH 2/7] fix(core): emit dbmldsa_sig from the dev license issuer QA tokens need the hybrid claim now that enforced mode requires it. The signer is a DEV ML-DSA-65 key, and the script refuses the packaged anchor private key. Refs #2043 --- CHANGELOG.md | 1 + docs/ops/QA_LOCAL_LICENSE.md | 6 +- docs/ops/QA_LOCAL_LICENSE.pt_BR.md | 6 +- scripts/issue_dev_license_jwt.py | 87 ++++++++++++++++++- tests/test_fingerprint_binding_deployments.py | 22 ++++- tests/test_issue_dev_license_qa.py | 67 +++++++++++++- 6 files changed, 182 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7dd95bd..7643e8cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Human-readable summary of user-facing changes. **Detailed release notes:** [docs Migration notes on the **`1.8.0-rc`** working line (no new semver cut): - **Hybrid license required (#2042):** enforced mode rejects a token that has no `dbmldsa_sig` (`hybrid_claim_required`) and rejects verification when the packaged ML-DSA anchor is missing (`missing_mldsa_anchor`). Ed25519-only licenses from 1.7.4 must be reissued before an enforced 1.8 deployment. `licensing.mode: open` is unchanged. +- **Dev issuer hybrid (#2043):** `scripts/issue_dev_license_jwt.py` attaches `dbmldsa_sig` with a DEV ML-DSA-65 key (`DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM_FILE`). It refuses the packaged anchor private key. An unset key is ephemeral and does not match the packaged anchor. - **FastAPI floor (#2089):** `fastapi>=0.143.0`. Native OpenTelemetry auto-config is opt-in (`FASTAPI_OTEL_AUTO_CONFIGURE=true` or `telemetry={"auto_configure": True}`). The app passes `telemetry={"auto_configure": False}` and still exports only when `DATA_BOAR_OTEL_ENABLED` is set. - **Credential env names (#2086):** `pass_from_env`, `password_from_env`, `user_from_env`, `token_from_env`, and `client_secret_from_env` fail at load when the name is outside the `DATA_BOAR_` / `REST_API_` / `API_` prefixes, or when the destination is missing from that target’s `allowed_hosts`. Shipped examples now use prefixed names (`DATA_BOAR_DEMO_DB_PASSWORD`, `DATA_BOAR_LAB_*`). diff --git a/docs/ops/QA_LOCAL_LICENSE.md b/docs/ops/QA_LOCAL_LICENSE.md index 14927827..7ffa653e 100644 --- a/docs/ops/QA_LOCAL_LICENSE.md +++ b/docs/ops/QA_LOCAL_LICENSE.md @@ -47,7 +47,11 @@ uv run python scripts/issue_dev_license_jwt.py \ ``` Defaults: `--dbtier enterprise --days 60 --dbmfp auto` (bound to the machine -that runs the command). +that runs the command). The issuer also attaches `dbmldsa_sig` (#2043) using +`DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM_FILE` (or the inline PEM env). +That key must be a DEV ML-DSA-65 private key. The script refuses the packaged +anchor private key. When the env is unset, the signature is ephemeral and will +not verify against the packaged anchor. ## 3. Issue for a tester's machine (remote) diff --git a/docs/ops/QA_LOCAL_LICENSE.pt_BR.md b/docs/ops/QA_LOCAL_LICENSE.pt_BR.md index 2d85a8ed..1c7f2427 100644 --- a/docs/ops/QA_LOCAL_LICENSE.pt_BR.md +++ b/docs/ops/QA_LOCAL_LICENSE.pt_BR.md @@ -49,7 +49,11 @@ uv run python scripts/issue_dev_license_jwt.py \ ``` Padrões: `--dbtier enterprise --days 60 --dbmfp auto` (vinculada à máquina -que executa o comando). +que executa o comando). O emissor também anexa `dbmldsa_sig` (#2043) com +`DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM_FILE` (ou o PEM inline). +Essa chave precisa ser uma privada ML-DSA-65 de DEV. O script recusa a +privada da âncora empacotada. Sem a variável, a assinatura é efêmera e não +verifica contra a âncora empacotada. ## 3. Emitir para a máquina de um testador (remoto) diff --git a/scripts/issue_dev_license_jwt.py b/scripts/issue_dev_license_jwt.py index d019b78c..b3ba0679 100644 --- a/scripts/issue_dev_license_jwt.py +++ b/scripts/issue_dev_license_jwt.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Issue a signed QA/lab license JWT (Ed25519) — the ONLY test path in enforced mode. +Issue a signed QA/lab license JWT (Ed25519 + ML-DSA-65) — the ONLY test path in enforced mode. The env-based tier bypass was removed (#719): QA against ``licensing.mode: enforced`` requires a real signed license. This issuer emits short-lived @@ -16,6 +16,8 @@ from __future__ import annotations import argparse +import base64 +import json import os import re import sys @@ -24,6 +26,7 @@ from pathlib import Path import jwt +from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA65PrivateKey from cryptography.hazmat.primitives.serialization import load_pem_private_key # Default QA validity: short enough that a leaked license dies on its own. @@ -36,6 +39,82 @@ sys.path.insert(0, _REPO_ROOT) +def _load_dev_mldsa_private() -> MLDSA65PrivateKey: + """DEV/QA ML-DSA signer. Never the packaged anchor private key. + + ``DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM_FILE`` or + ``DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM`` supplies the key. + When both are unset, an ephemeral key is generated for this process. + That token will not verify against the packaged anchor; tests pin the + matching public key in-process. This is not a production verify override. + """ + path = os.environ.get( + "DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM_FILE", "" + ).strip() + inline = os.environ.get( + "DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM", "" + ).strip() + if path: + blob = Path(path).expanduser().read_text(encoding="utf-8") + elif inline: + blob = inline + else: + return MLDSA65PrivateKey.generate() + key = load_pem_private_key(blob.encode("utf-8"), password=None) + if not isinstance(key, MLDSA65PrivateKey): + print( + "DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_* must be an ML-DSA-65 private key", + file=sys.stderr, + ) + sys.exit(1) + _reject_packaged_mldsa_anchor(key) + return key + + +def _reject_packaged_mldsa_anchor(key: MLDSA65PrivateKey) -> None: + from core.licensing.trust_anchor import load_embedded_mldsa_anchor_pem + from core.licensing.verify import load_mldsa65_public_key_pem + + embedded = load_embedded_mldsa_anchor_pem() + if not embedded: + return + packaged = load_mldsa65_public_key_pem(embedded) + if key.public_key().public_bytes_raw() == packaged.public_bytes_raw(): + print( + "refusing to sign with the packaged ML-DSA anchor private key", + file=sys.stderr, + ) + sys.exit(1) + + +def _attach_dev_mldsa_sig( + token: str, ed_private: object, mldsa_private: MLDSA65PrivateKey +) -> str: + from core.licensing.verify import CLAIM_MLDSA_SIG, _mldsa_payload_json_without_sig + + payload_b64 = token.split(".")[1] + claims = json.loads( + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + ) + if not isinstance(claims, dict): + print("issuer payload is not an object", file=sys.stderr) + sys.exit(1) + header_b64 = token.split(".", 1)[0] + payload_json = _mldsa_payload_json_without_sig(claims) + signed_payload_b64 = ( + base64.urlsafe_b64encode(payload_json.encode("utf-8")) + .decode("ascii") + .rstrip("=") + ) + sig = mldsa_private.sign(f"{header_b64}.{signed_payload_b64}".encode("utf-8")) + claims[CLAIM_MLDSA_SIG] = base64.urlsafe_b64encode(sig).decode("ascii").rstrip("=") + sealed = jwt.encode(claims, ed_private, algorithm="EdDSA") + if sealed.split(".", 1)[0] != header_b64: + print("JWT header changed after attaching dbmldsa_sig", file=sys.stderr) + sys.exit(1) + return sealed + + def _machine_fingerprint() -> str: from core.licensing.fingerprint import compute_machine_fingerprint @@ -244,7 +323,11 @@ def main() -> None: payload["dbmax_deployments"] = args.dbmax_deployments if args.pack_id: payload["dbdeployment_pack_id"] = args.pack_id - token = jwt.encode(payload, key, algorithm="EdDSA") + token = _attach_dev_mldsa_sig( + jwt.encode(payload, key, algorithm="EdDSA"), + key, + _load_dev_mldsa_private(), + ) if args.out: Path(args.out).write_text(token + "\n", encoding="utf-8") else: diff --git a/tests/test_fingerprint_binding_deployments.py b/tests/test_fingerprint_binding_deployments.py index c10dc4ad..550de4b5 100644 --- a/tests/test_fingerprint_binding_deployments.py +++ b/tests/test_fingerprint_binding_deployments.py @@ -319,9 +319,28 @@ def _run_issuer(args: list[str], env_extra: dict[str, str]) -> str: def test_issuer_dbmfp_pack_binds_n_machines(ed25519_priv, tmp_path): + from cryptography.hazmat.primitives import serialization + + from tests.hybrid_license_fixture import ( + pin_test_mldsa_anchor, + test_mldsa_private, + test_mldsa_public_pem, + ) + + ml_path = tmp_path / "dev-mldsa.pem" + ml_path.write_bytes( + test_mldsa_private().private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) token = _run_issuer( ["--dbmfp-pack", f"auto,{OTHER_FP}", "--sub", "qa-pack-lic"], - {"DATA_BOAR_LICENSE_ISSUER_PRIVATE_KEY_PEM": _pem_private(ed25519_priv)}, + { + "DATA_BOAR_LICENSE_ISSUER_PRIVATE_KEY_PEM": _pem_private(ed25519_priv), + "DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM_FILE": str(ml_path), + }, ) claims = jwt.decode( token, @@ -338,6 +357,7 @@ def test_issuer_dbmfp_pack_binds_n_machines(ed25519_priv, tmp_path): lic = tmp_path / "pack.lic" lic.write_text(token, encoding="utf-8") pin_embedded_ed25519_pem(_pem_public(ed25519_priv)) + pin_test_mldsa_anchor(test_mldsa_public_pem()) g = LicenseGuard({"licensing": {"mode": "enforced", "license_path": str(lic)}}) assert g.context.state == "VALID" assert g.context.max_deployments == 2 diff --git a/tests/test_issue_dev_license_qa.py b/tests/test_issue_dev_license_qa.py index 40f85972..a1d6b99e 100644 --- a/tests/test_issue_dev_license_qa.py +++ b/tests/test_issue_dev_license_qa.py @@ -25,6 +25,11 @@ from core.licensing.fingerprint import compute_machine_fingerprint from core.licensing.guard import LicenseGuard from core.licensing import reset_license_guard_for_tests +from core.licensing.verify import CLAIM_MLDSA_SIG +from tests.hybrid_license_fixture import ( + pin_test_mldsa_anchor, + test_mldsa_public_pem as dev_mldsa_public_pem, +) from tests.license_verify_pin import pin_embedded_ed25519_pem REPO_ROOT = Path(__file__).resolve().parent.parent @@ -73,6 +78,29 @@ def _clean_guard(): os.environ.pop("DATA_BOAR_LICENSE_PUBLIC_KEY_PEM", None) +def _dev_mldsa_file(tmp_path: Path) -> Path: + from cryptography.hazmat.primitives import serialization + from tests.hybrid_license_fixture import test_mldsa_private + + pem = test_mldsa_private().private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + path = tmp_path / "dev-mldsa.pem" + path.write_bytes(pem) + return path + + +def _issuer_env(tmp_path: Path, **extra: str) -> dict[str, str]: + env = dict(os.environ) + env["DATA_BOAR_LICENSE_ISSUER_MLDSA_PRIVATE_KEY_PEM_FILE"] = str( + _dev_mldsa_file(tmp_path) + ) + env.update(extra) + return env + + def _run_issuer(*argv: str, env: dict | None = None) -> str: proc = _run_issuer_proc(*argv, env=env) assert proc.returncode == 0, proc.stderr @@ -105,6 +133,7 @@ def test_issuer_defaults_60d_enterprise_machine_bound(keypair): assert 59.9 <= lifetime_days <= 60.1 # Machine-bound by default to the issuing machine. assert claims["dbmfp"] == compute_machine_fingerprint() + assert isinstance(claims[CLAIM_MLDSA_SIG], str) and claims[CLAIM_MLDSA_SIG] def test_issuer_show_machine_fingerprint(keypair): @@ -122,14 +151,46 @@ def test_issuer_dbmfp_none_unbound(keypair): def test_bound_license_valid_on_this_machine(keypair, tmp_path): priv_path, pub_pem, _pub_key = keypair lic = tmp_path / "qa.lic" - _run_issuer("--private-key-pem-file", str(priv_path), "--out", str(lic)) + _run_issuer( + "--private-key-pem-file", + str(priv_path), + "--out", + str(lic), + env=_issuer_env(tmp_path), + ) pin_embedded_ed25519_pem(pub_pem) + pin_test_mldsa_anchor(dev_mldsa_public_pem()) g = LicenseGuard({"licensing": {"mode": "enforced", "license_path": str(lic)}}) - assert g.context.state == "VALID" + assert g.context.state == "VALID", g.context.detail + assert "hybrid_mldsa65_verified" in g.context.detail assert g.context.dbtier == "enterprise" assert g.allows_scan() is True +def test_dev_token_without_claim_is_rejected(keypair, tmp_path): + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + priv_path, pub_pem, pub_key = keypair + token = _run_issuer( + "--private-key-pem-file", + str(priv_path), + env=_issuer_env(tmp_path), + ) + claims = jwt.decode( + token, pub_key, algorithms=["EdDSA"], options={"verify_exp": False} + ) + claims.pop(CLAIM_MLDSA_SIG) + ed_private = load_pem_private_key(priv_path.read_bytes(), password=None) + bare = jwt.encode(claims, ed_private, algorithm="EdDSA") + lic = tmp_path / "bare.lic" + lic.write_text(bare, encoding="utf-8") + pin_embedded_ed25519_pem(pub_pem) + pin_test_mldsa_anchor(dev_mldsa_public_pem()) + g = LicenseGuard({"licensing": {"mode": "enforced", "license_path": str(lic)}}) + assert g.context.state == "INVALID" + assert g.context.detail == "hybrid_claim_required" + + def test_bound_license_rejected_on_other_machine(keypair, tmp_path): priv_path, pub_pem, _pub_key = keypair lic = tmp_path / "qa.lic" @@ -140,8 +201,10 @@ def test_bound_license_rejected_on_other_machine(keypair, tmp_path): "f" * 64, # some other machine's fingerprint "--out", str(lic), + env=_issuer_env(tmp_path), ) pin_embedded_ed25519_pem(pub_pem) + pin_test_mldsa_anchor(dev_mldsa_public_pem()) g = LicenseGuard({"licensing": {"mode": "enforced", "license_path": str(lic)}}) assert g.context.state == "MACHINE_MISMATCH" assert g.allows_scan() is False From cb9c8f45bed82dd61a64602b251b2c3f454f95b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 13:34:45 -0300 Subject: [PATCH 3/7] fix(connectors): open scanned sqlite files without URL unquoting SQLAlchemy 2.1 percent-decodes a sqlite URL built from the filename. URL.create keeps the path that was scanned. Refs #2095 --- connectors/filesystem_connector.py | 6 +++++- tests/test_sampling_dedup_before_cap.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/connectors/filesystem_connector.py b/connectors/filesystem_connector.py index 3dc645ff..46c37fe4 100644 --- a/connectors/filesystem_connector.py +++ b/connectors/filesystem_connector.py @@ -597,6 +597,7 @@ def _scan_sqlite_file_as_db( No raw content stored. """ from sqlalchemy import create_engine, inspect + from sqlalchemy.engine import URL from connectors.sql_sampling import SamplingManager, resolve_sql_sample_limit from connectors.sample_value_dedup import ( @@ -606,7 +607,10 @@ def _scan_sqlite_file_as_db( findings = [] try: - engine = create_engine(f"sqlite:///{file_path.resolve()}", pool_pre_ping=True) + engine = create_engine( + URL.create("sqlite", database=str(file_path.resolve())), + pool_pre_ping=True, + ) except Exception: return [] try: diff --git a/tests/test_sampling_dedup_before_cap.py b/tests/test_sampling_dedup_before_cap.py index 5be832d3..2fc1de49 100644 --- a/tests/test_sampling_dedup_before_cap.py +++ b/tests/test_sampling_dedup_before_cap.py @@ -119,6 +119,21 @@ def test_sqlite_filesystem_scan_finds_rare_cpf(tmp_path): ) or any(f.get("sensitivity_level") != "LOW" for f in findings) +def test_sqlite_scan_opens_filename_with_percent_encoding(tmp_path): + """SQLAlchemy 2.1 must not percent-decode the scanned filename (#2095).""" + from connectors.filesystem_connector import _scan_sqlite_file_as_db + + db_path = tmp_path / "report%2Fcopy.db" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE t (payload TEXT)") + conn.execute("INSERT INTO t (payload) VALUES (?)", (f"ERR {_RARE_CPF}",)) + conn.commit() + conn.close() + + findings = _scan_sqlite_file_as_db(db_path, DataScanner(), sample_limit=5) + assert findings, db_path.name + + def test_mongodb_dedup_fetches_more_docs_for_distinct_values(): from connectors.mongodb_connector import MongoDBConnector From df36cbe109db0c131698614332769827a606a77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 13:42:47 -0300 Subject: [PATCH 4/7] fix(api): require JSON for POST /scan and /start A body without application/json is rejected, and a cross-site Sec-Fetch-Site header is rejected. The dashboard already sends JSON. Refs #2096 --- api/routes.py | 14 +++++++++++++- tests/test_api_scan.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index ff921482..aefb3046 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1406,12 +1406,24 @@ def _build_chart_data(sessions: list[dict]) -> list[dict]: } +def _reject_non_json_or_cross_site_scan(request: Request) -> None: + """POST /scan and /start are not CORS-simple requests (#2096).""" + media = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if media != "application/json": + raise HTTPException(status_code=415, detail="application/json required") + if request.headers.get("sec-fetch-site", "").strip().lower() == "cross-site": + raise HTTPException(status_code=403, detail="cross-site scan rejected") + + @app.post("/scan", responses=_SCAN_START_RESPONSES) @app.post("/start", responses=_SCAN_START_RESPONSES) async def start_scan( - background_tasks: BackgroundTasks, body: ScanStartBody | None = None + request: Request, + background_tasks: BackgroundTasks, + body: ScanStartBody | None = None, ): """Start audit in background. Optional body: tenant, technician, scan_compressed, content_type_check, scan_for_stego, scheduled, jurisdiction_hint, validate_crypto. Returns session_id. HTTP 409 if a scan is already running.""" + _reject_non_json_or_cross_site_scan(request) _raise_if_license_blocks_scan() if body and getattr(body, "scheduled", None) is True: _raise_if_feature_blocked("scheduled_scans") diff --git a/tests/test_api_scan.py b/tests/test_api_scan.py index f45bea30..16ba45cb 100644 --- a/tests/test_api_scan.py +++ b/tests/test_api_scan.py @@ -6,6 +6,46 @@ from fastapi.testclient import TestClient +def test_scan_and_start_reject_missing_json_content_type(tmp_path): + """POST /scan and /start require application/json (#2096).""" + out_dir = str(tmp_path).replace("\\", "/") + config_path = tmp_path / "config.yaml" + config_path.write_text( + f"""targets: [] +report: + output_dir: {out_dir} +sqlite_path: {out_dir}/audit_results.db +""", + encoding="utf-8", + ) + import api.routes as routes + + original_config_path = routes._config_path + original_config = routes._config + original_engine = routes._audit_engine + try: + routes._config_path = str(config_path) + routes._config = None + routes._audit_engine = None + client = TestClient(routes.app) + for path in ("/scan", "/start"): + resp = client.post(path) + assert resp.status_code == 415, (path, resp.status_code, resp.text) + cross = client.post( + path, + headers={ + "Content-Type": "application/json", + "Sec-Fetch-Site": "cross-site", + }, + content=b"{}", + ) + assert cross.status_code == 403, (path, cross.status_code, cross.text) + finally: + routes._config_path = original_config_path + routes._config = original_config + routes._audit_engine = original_engine + + def test_post_scan_triggers_audit_using_config(tmp_path): """ POST /scan must start a background audit that uses targets from the config file. From 4f040a9a6828d7b9cb88423a4210a7d4bf93fa56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 13:45:24 -0300 Subject: [PATCH 5/7] fix(core): serve the config file the bind gate validated The API follows --config. A different CONFIG_PATH on a non-loopback bind is refused so the process cannot load a second YAML. Refs #2091 --- api/routes.py | 8 ++++++++ core/host_resolution.py | 18 +++++++++++++++++ main.py | 21 +++++++++++++++++++- tests/test_served_config_path.py | 33 ++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/test_served_config_path.py diff --git a/api/routes.py b/api/routes.py index aefb3046..87819eff 100644 --- a/api/routes.py +++ b/api/routes.py @@ -587,6 +587,14 @@ class ScanStartBody(BaseModel): _audit_engine = None +def use_validated_config_path(path: str) -> None: + """Point the API at the config file ``main.py`` already validated (#2091).""" + global _config_path, _config, _audit_engine + _config_path = path + _config = None + _audit_engine = None + + def _get_config_path() -> str: """Return path to config file (for dashboard display and save).""" return _config_path diff --git a/core/host_resolution.py b/core/host_resolution.py index 73bb7a55..1b5d5425 100644 --- a/core/host_resolution.py +++ b/core/host_resolution.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from pathlib import Path from typing import Any @@ -78,6 +79,23 @@ def trusted_api_hosts(config: dict[str, Any]) -> list[str]: return sorted(hosts) +def resolve_served_config_path( + *, cli_config: str, env_config: str | None, host: str +) -> str: + """Return the config file the bind gate already loaded. + + A different ``CONFIG_PATH`` on a non-loopback bind is refused. The served + file is always ``cli_config`` so the API cannot load a second YAML. + """ + cli = str(Path(cli_config).resolve()) + env = (env_config or "").strip() + if env: + env_resolved = str(Path(env).resolve()) + if env_resolved != cli and api_bind_exposes_non_loopback(host): + raise ValueError("config_path_mismatch") + return cli + + def api_bind_exposes_non_loopback(host: str) -> bool: """ True when the API listens beyond loopback (e.g. 0.0.0.0, LAN IP, ::), so clients on diff --git a/main.py b/main.py index d23253d2..ced2890b 100644 --- a/main.py +++ b/main.py @@ -1910,7 +1910,7 @@ def main() -> None: _emit_runtime_trust_info(runtime_trust, to_stdout=True, to_stderr=True) import uvicorn - from api.routes import app + from api.routes import app, use_validated_config_path from core.dashboard_transport import ( configure_dashboard_transport, resolve_web_listen_options, @@ -1918,6 +1918,7 @@ def main() -> None: from core.host_resolution import ( effective_api_key_configured, resolve_api_host, + resolve_served_config_path, should_block_non_loopback_without_auth, should_warn_insecure_api_bind, ) @@ -1940,6 +1941,24 @@ def main() -> None: port = api_cfg.get("port", args.port) workers = int(api_cfg.get("workers", 1)) host = resolve_api_host(config, cli_host=args.host) + try: + served_config = resolve_served_config_path( + cli_config=args.config, + env_config=os.environ.get("CONFIG_PATH"), + host=host, + ) + except ValueError as exc: + if str(exc) != "config_path_mismatch": + raise + print( + "ERROR: --config and CONFIG_PATH point at different files. " + "The API would not serve the config this bind gate validated. " + "Pass the same path to --config or unset CONFIG_PATH.", + file=sys.stderr, + flush=True, + ) + sys.exit(2) + use_validated_config_path(served_config) if should_block_non_loopback_without_auth(config, host): print( "ERROR: Refusing startup with non-loopback API bind and unresolved auth boundary. " diff --git a/tests/test_served_config_path.py b/tests/test_served_config_path.py new file mode 100644 index 00000000..211e1fa9 --- /dev/null +++ b/tests/test_served_config_path.py @@ -0,0 +1,33 @@ +"""The web process serves the config the bind gate already validated (#2091).""" + +from __future__ import annotations + +import pytest + +from core.host_resolution import resolve_served_config_path + + +def test_non_loopback_mismatch_fails_closed(tmp_path): + cli = tmp_path / "secure.yaml" + other = tmp_path / "other.yaml" + cli.write_text("api: {}\n", encoding="utf-8") + other.write_text("api: {}\n", encoding="utf-8") + with pytest.raises(ValueError, match="config_path_mismatch"): + resolve_served_config_path( + cli_config=str(cli), + env_config=str(other), + host="0.0.0.0", + ) + + +def test_served_path_is_the_cli_file_on_loopback(tmp_path): + cli = tmp_path / "secure.yaml" + other = tmp_path / "other.yaml" + cli.write_text("api: {}\n", encoding="utf-8") + other.write_text("api: {}\n", encoding="utf-8") + served = resolve_served_config_path( + cli_config=str(cli), + env_config=str(other), + host="127.0.0.1", + ) + assert served == str(cli.resolve()) From f1cd0ea4f9cd0bb781986bb914525095199426ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 13:46:16 -0300 Subject: [PATCH 6/7] fix(core): keep configured RBAC fail-closed below Pro api.rbac.enabled no longer turns off when the enforced tier drops. Unauthenticated calls stay denied. Refs #2092 --- core/rbac_settings.py | 20 +++++++++----------- tests/test_rbac.py | 14 +++++++++----- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/core/rbac_settings.py b/core/rbac_settings.py index 747c6f1c..ea436630 100644 --- a/core/rbac_settings.py +++ b/core/rbac_settings.py @@ -1,24 +1,22 @@ -"""RBAC feature gate (GitHub #86 Phase 2): config + Pro tier per ``tier_features.dashboard_rbac``.""" +"""RBAC feature gate (GitHub #86 Phase 2). + +A configured ``api.rbac.enabled`` stays fail-closed when the enforced tier +drops below Pro (#2092). Turning the flag off is what disables the gate. +""" from __future__ import annotations from typing import Any -from core.licensing.runtime_feature_tier import get_runtime_tier_for_features -from core.licensing.tier_features import is_feature_available - def rbac_enforcement_active(config: dict[str, Any]) -> bool: - """ - True when ``api.rbac.enabled`` is set and the deployment tier allows ``dashboard_rbac``. + """True when ``api.rbac.enabled`` is set. - Community tier cannot enable in-product RBAC; OPEN/dev mode keeps all features available. + The tier does not turn a configured gate off. A Community or degraded + license keeps denying unauthenticated calls instead of widening access. """ api = config.get("api") if isinstance(config.get("api"), dict) else {} raw = api.get("rbac") if not isinstance(raw, dict) or not raw.get("enabled"): return False - return is_feature_available( - "dashboard_rbac", - get_runtime_tier_for_features(config), - ) + return True diff --git a/tests/test_rbac.py b/tests/test_rbac.py index 608a98c6..9ff9f384 100644 --- a/tests/test_rbac.py +++ b/tests/test_rbac.py @@ -75,8 +75,9 @@ def rbac_pro_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("DATA_BOAR_WEBAUTHN_TOKEN_SECRET", raising=False) -def test_rbac_enforcement_active_requires_tier_and_flag(): - assert not rbac_enforcement_active( +def test_rbac_enforcement_stays_closed_when_tier_drops(): + """Configured RBAC does not turn off when the tier falls below Pro (#2092).""" + assert rbac_enforcement_active( { "api": {"rbac": {"enabled": True}}, "licensing": {"effective_tier": "community"}, @@ -85,6 +86,9 @@ def test_rbac_enforcement_active_requires_tier_and_flag(): assert rbac_enforcement_active( {"api": {"rbac": {"enabled": True}}, "licensing": {"effective_tier": "pro"}} ) + assert not rbac_enforcement_active( + {"api": {"rbac": {"enabled": False}}, "licensing": {"effective_tier": "pro"}} + ) def test_rbac_401_without_principal_on_protected_route(rbac_pro_client): @@ -100,8 +104,8 @@ def test_rbac_api_key_dashboard_allows_status_forbids_report(rbac_pro_client): assert client.get("/report", headers=h).status_code == 403 -def test_rbac_community_yaml_enabled_does_not_enforce(tmp_path: Path, monkeypatch): - """Tier blocks dashboard_rbac: middleware is a no-op (OPEN behaviour without RBAC).""" +def test_rbac_community_yaml_enabled_stays_fail_closed(tmp_path: Path, monkeypatch): + """A configured RBAC gate stays closed when the tier is Community (#2092).""" monkeypatch.setenv( "DATA_BOAR_WEBAUTHN_TOKEN_SECRET", "unit-test-webauthn-secret-min-16" ) @@ -133,7 +137,7 @@ def test_rbac_community_yaml_enabled_does_not_enforce(tmp_path: Path, monkeypatc routes._audit_engine = None client = TestClient(routes.app) try: - assert client.get("/status").status_code == 200 + assert client.get("/status").status_code == 401 finally: routes._config_path = prev_path routes._config = prev_cfg From a76f4630616da735c6074830588ad05aedbe090d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 14:11:15 -0300 Subject: [PATCH 7/7] fix(tests): isolate main --web from the operator CONFIG_PATH The non-loopback bind tests inherit the shell environment. Drop CONFIG_PATH so they reach the auth refusal instead of the config mismatch exit. Refs #2091 --- tests/test_main_web_transport_cli.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_main_web_transport_cli.py b/tests/test_main_web_transport_cli.py index ba92cc8f..b786477c 100644 --- a/tests/test_main_web_transport_cli.py +++ b/tests/test_main_web_transport_cli.py @@ -7,6 +7,18 @@ import time from pathlib import Path +import pytest + + +@pytest.fixture(autouse=True) +def _drop_operator_config_path(monkeypatch: pytest.MonkeyPatch) -> None: + """Child ``main.py --web`` inherits this process env (#2091). + + A shell ``CONFIG_PATH`` that is not the test ``--config`` exits on a + non-loopback bind before the auth refusal these tests assert. + """ + monkeypatch.delenv("CONFIG_PATH", raising=False) + def test_main_web_exits_without_transport_choice(tmp_path): cfg = tmp_path / "c.yaml"