Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ 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_*`).
- **`GET /health` (#2077):** the unauthenticated body is only `{"status":"ok"}`. License, trust, transport, and integrity moved to authenticated `GET /status`.
Expand Down
22 changes: 21 additions & 1 deletion api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1406,12 +1414,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")
Expand Down
6 changes: 5 additions & 1 deletion connectors/filesystem_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions core/host_resolution.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
from pathlib import Path
from typing import Any


Expand Down Expand Up @@ -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
Expand Down
33 changes: 22 additions & 11 deletions core/licensing/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 10 additions & 11 deletions core/licensing/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 9 additions & 11 deletions core/rbac_settings.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/LICENSE_FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/LICENSE_FAQ.pt_BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions docs/LICENSING_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading