diff --git a/.github/workflows/commons-release.yml b/.github/workflows/commons-release.yml new file mode 100644 index 0000000..e94b725 --- /dev/null +++ b/.github/workflows/commons-release.yml @@ -0,0 +1,167 @@ +name: Signed Commons release + +on: + workflow_dispatch: + schedule: + - cron: "*/10 * * * *" + +permissions: + contents: read + +concurrency: + group: commons-production-release + cancel-in-progress: false + +jobs: + release: + if: github.repository == 'SignalLayerLabs/Marginal' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: commons-production + timeout-minutes: 10 + steps: + - name: Checkout trusted MARGINAL + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + path: marginal + persist-credentials: false + + - name: Checkout Commons as data + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + repository: SignalLayerLabs/Marginal-Commons + ref: main + fetch-depth: 0 + path: commons-data + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13.7" + + - name: Install trusted release dependencies + working-directory: marginal + run: >- + python -m pip install + --disable-pip-version-check + --only-binary=:all: + --require-hashes + --no-cache-dir + --requirement requirements/commons-release.txt + + - name: Build signed candidate + env: + COMMONS_RELEASE_PRIVATE_KEY_B64URL: ${{ secrets.COMMONS_RELEASE_PRIVATE_KEY_B64URL }} + run: >- + python marginal/scripts/build_commons_release.py + --commons-repo commons-data + --revision HEAD + --output-dir candidate/dist + + - name: Independently verify candidate + run: >- + python marginal/scripts/build_commons_release.py + --verify-pack candidate/dist/commons-pack-v1.json + --verify-signature candidate/dist/commons-pack-v1.sig.json + + - name: Compare current signed production state + id: production + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + shell: bash + run: | + set -euo pipefail + mkdir -p current/dist + pack_status="$(curl --silent --show-error --location --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --max-filesize 3145728 \ + --output current/dist/commons-pack-v1.json --write-out '%{http_code}' \ + https://marginal-commons.pages.dev/dist/commons-pack-v1.json)" + signature_status="$(curl --silent --show-error --location --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --max-filesize 1048576 \ + --output current/dist/commons-pack-v1.sig.json --write-out '%{http_code}' \ + https://marginal-commons.pages.dev/dist/commons-pack-v1.sig.json)" + if [ "${signature_status}" = "404" ]; then + test "${pack_status}" = "200" + deployment_page=1 + : > current/deployment-urls.txt + while :; do + curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --max-filesize 1048576 \ + --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/marginal-commons/deployments?env=production&per_page=100&page=${deployment_page}" \ + --output current/deployments.json + total_pages="$(DEPLOYMENT_PAGE="${deployment_page}" python - <<'PY' + import json + import os + import re + from pathlib import Path + + payload = json.loads(Path("current/deployments.json").read_text(encoding="utf-8")) + if not isinstance(payload, dict) or payload.get("success") is not True: + raise SystemExit("Cloudflare deployment history is invalid") + deployments = payload.get("result") + result_info = payload.get("result_info") + if not isinstance(deployments, list) or not isinstance(result_info, dict): + raise SystemExit("Cloudflare deployment history is invalid") + current_page = result_info.get("page") + total_pages = result_info.get("total_pages") + expected_page = int(os.environ["DEPLOYMENT_PAGE"]) + if ( + isinstance(current_page, bool) + or not isinstance(current_page, int) + or current_page != expected_page + or isinstance(total_pages, bool) + or not isinstance(total_pages, int) + or not expected_page <= total_pages <= 10_000 + ): + raise SystemExit("Cloudflare deployment pagination is invalid") + pattern = re.compile(r"https://[a-z0-9-]+\.marginal-commons\.pages\.dev\Z") + with Path("current/deployment-urls.txt").open("a", encoding="utf-8") as output: + for deployment in deployments: + url = deployment.get("url") if isinstance(deployment, dict) else None + if not isinstance(url, str) or pattern.fullmatch(url) is None: + raise SystemExit("Cloudflare deployment history contains an invalid URL") + print(url, file=output) + print(total_pages) + PY + )" + if [ "${deployment_page}" -ge "${total_pages}" ]; then + break + fi + deployment_page=$((deployment_page + 1)) + done + while IFS= read -r deployment_url; do + historical_status="$(curl --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --max-filesize 1048576 \ + --output /dev/null --write-out '%{http_code}' \ + "${deployment_url}/dist/commons-pack-v1.sig.json")" + if [ "${historical_status}" = "200" ]; then + echo "A signed production deployment already exists; missing current signature fails closed." + exit 1 + fi + test "${historical_status}" = "404" + done < current/deployment-urls.txt + echo "deploy=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + test "${signature_status}" = "200" + test "${pack_status}" = "200" + python marginal/scripts/build_commons_release.py \ + --verify-pack current/dist/commons-pack-v1.json \ + --verify-signature current/dist/commons-pack-v1.sig.json + python marginal/scripts/build_commons_release.py \ + --verify-pack candidate/dist/commons-pack-v1.json \ + --verify-signature candidate/dist/commons-pack-v1.sig.json \ + --current-pack current/dist/commons-pack-v1.json \ + --current-signature current/dist/commons-pack-v1.sig.json \ + >> "${GITHUB_OUTPUT}" + + - name: Deploy signed release + if: steps.production.outputs.deploy == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: >- + npx wrangler@4.124.0 pages deploy candidate + --project-name marginal-commons diff --git a/MANIFEST.in b/MANIFEST.in index c0853d9..96a398f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,5 +10,8 @@ include ROADMAP.md recursive-include schemas *.json recursive-include contracts *.json recursive-include demos *.md *.json *.html *.svg *.jsonl +include scripts/build_commons_release.py +include models/canonical-model-registry-v1.json +include requirements/commons-release.txt recursive-include assets *.png diff --git a/contracts/commons-release-key-v1.json b/contracts/commons-release-key-v1.json new file mode 100644 index 0000000..7991c27 --- /dev/null +++ b/contracts/commons-release-key-v1.json @@ -0,0 +1 @@ +{"algorithm":"ed25519","key_id":"commons-release-962b690a695e079d","not_after_revision":2147483647,"not_before_revision":1,"public_key":"mGvL2Rpdx-A2Rf1bg8BJ0GqI2F1TQ9VK9HeEUYHYeg0","schema_version":"1.0"} diff --git a/contracts/commons-release-key-v1.sig.json b/contracts/commons-release-key-v1.sig.json new file mode 100644 index 0000000..a408130 --- /dev/null +++ b/contracts/commons-release-key-v1.sig.json @@ -0,0 +1 @@ +{"algorithm":"ed25519","key_id":"commons-root-v1","schema_version":"1.0","signature":"G1LhoyR2ERbXFDY5WpqFQBUjSRZwcQchurVIJUZUV6-lapB8rRUDqmMJjpYaEQ4S9S3t_SGy9dOQLS8ELBy5Dw"} diff --git a/contracts/commons-root-key-v1.json b/contracts/commons-root-key-v1.json new file mode 100644 index 0000000..255dfa9 --- /dev/null +++ b/contracts/commons-root-key-v1.json @@ -0,0 +1 @@ +{"algorithm":"ed25519","key_id":"commons-root-v1","public_key":"NLeHfzgR6FIun-jSoeTwKss1qJbEvFNxUNdSzWvNT1U","schema_version":"1.0"} diff --git a/docs/commons-release-security.md b/docs/commons-release-security.md new file mode 100644 index 0000000..cad626e --- /dev/null +++ b/docs/commons-release-security.md @@ -0,0 +1,54 @@ +# Signed Commons release operations + +MARGINAL Commons releases use an offline Ed25519 root to certify an online release key. MARGINAL +runtime distributions contain the root public key only. Each detached release envelope carries the +root-signed release certificate and a release-key signature over the exact pack bytes. + +## Key custody and rotation + +Keep the root private key offline and outside developer machines, CI, and repository storage. The +online release seed belongs only in the `commons-production` GitHub Environment as +`COMMONS_RELEASE_PRIVATE_KEY_B64URL`. Restrict that environment and the Cloudflare token to the +smallest practical maintainer and deployment scope. + +To rotate the online key, create a new closed release certificate with a unique key ID and bounded +revision interval, sign its canonical JSON bytes offline with the root, review the three public +contract files, and update MARGINAL before using the new release seed. Overlap revision intervals +only when an intentional rollout needs it. Do not overwrite an existing key ID with different key +material. + +If an online release key may be compromised, remove it from the production environment, stop the +release workflow, and ship a MARGINAL update whose trusted policy no longer accepts its certificate +before resuming publication with a newly certified key. Revision bounds limit where a certificate +is accepted but are not a network revocation mechanism. A root-key compromise requires a new root +anchor and a MARGINAL software update; signatures already trusted by old clients cannot be remotely +revoked. + +## Publication behavior + +The scheduled and manually dispatched workflow runs only from `SignalLayerLabs/Marginal` `main` in +the protected `commons-production` environment. It checks out Marginal-Commons with full history as +data, reads immutable Git objects, never imports or executes Commons code, builds a deterministic +candidate, and verifies it with both the runtime verifier and `cryptography` before comparison. +The official checkout and Python setup actions are pinned to immutable commits. The signing job +installs only exact-version binary release dependencies accepted by the reviewed SHA-256 lock file; +the private seed is exposed only to the candidate-build step and must never be logged or persisted. + +The first signed deployment may replace the existing unsigned production pack when the signature +path is absent and the complete paginated Cloudflare deployment history contains no earlier signed +release. This is the one bootstrap exception. Once any signed production deployment exists, a +missing, malformed, +rollback, or same-revision-conflicting production artifact makes publication fail closed. Wrangler +is pinned to 4.124.0 and deploys only a verified candidate containing the two fixed `dist/` paths. + +Runtime consumption has the opposite availability posture: network, signature, schema, and cache +failures return no shared prior and never block local work or Contributor outbox processing. A +signed Commons pack authenticates its release chain and bytes; it does not prove that upstream +observations are correct. + +## Authority boundary + +Commons remains a non-authoritative prior. Its signatures and lifecycle labels cannot activate +Tool Enforcement, promote Autopilot, change thresholds, override local evidence, or grant any local +authority. Local Only remains the default, sharing still requires explicit Contributor mode, and +Sol, Terra, and Luna priors remain isolated by exact canonical namespace. diff --git a/docs/superpowers/plans/2026-08-21-signed-commons-trust-path.md b/docs/superpowers/plans/2026-08-21-signed-commons-trust-path.md new file mode 100644 index 0000000..2947181 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-signed-commons-trust-path.md @@ -0,0 +1,108 @@ +# Signed Commons Trust Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Authenticate exact Commons pack bytes through an offline-root-certified release key before MARGINAL caches or uses model-specific priors. + +**Architecture:** A stdlib-only strict Ed25519 verifier and closed trust parser sit before the existing pack parser. The client downloads a fixed pack/signature pair, the cache atomically persists one signed artifact with anti-rollback, and trusted release tooling builds from immutable Git objects and signs only after verifying the frozen public chain. + +**Tech Stack:** Python 3.10+ stdlib at runtime; pytest, cryptography, jsonschema, Git, GitHub Actions, Cloudflare Wrangler 4.124.0 for tests/release tooling. + +**Spec:** `docs/superpowers/specs/2026-08-21-signed-commons-trust-path-design.md` + +## Global Constraints + +- Keep `pyproject.toml` production `dependencies = []`. +- Never access, display, persist, or log a private signing key. +- Never import or execute Marginal-Commons code or trust its `dist/` directory. +- Keep Commons prior-only, exact-model-isolated, and fail-open at runtime. +- Do not commit, push, merge, switch branches, or modify Marginal-Commons. + +--- + +### Task 1: Strict Ed25519 and signed-envelope verification + +**Files:** +- Create: `src/marginal/commons/ed25519.py` +- Create: `src/marginal/commons/trust.py` +- Create: `src/marginal/commons/commons-root-key-v1.json` +- Test: `tests/commons/test_ed25519.py` +- Test: `tests/commons/test_trust.py` + +**Interfaces:** +- Produces: `verify_ed25519(public_key: bytes, message: bytes, signature: bytes) -> bool` +- Produces: `verify_signed_pack(pack: bytes, signature: bytes) -> VerifiedCommonsPack` + +- [ ] Write RFC 8032 and strict-rejection tests using literal vectors and independently generated cryptography fixtures. +- [ ] Run `pytest -q tests/commons/test_ed25519.py tests/commons/test_trust.py` and observe missing-interface failures. +- [ ] Implement strict base64url, point decoding/subgroup checks, certificate/envelope parsing, and chain verification. +- [ ] Run the targeted tests to green. + +### Task 2: Signed atomic cache and fixed-path paired download + +**Files:** +- Modify: `src/marginal/commons/cache.py` +- Modify: `src/marginal/commons/client.py` +- Modify: `src/marginal/commons/sync.py` +- Modify: `src/marginal/commons/__init__.py` +- Test: `tests/commons/test_cache.py` +- Test: `tests/commons/test_client.py` +- Test: `tests/commons/test_sync.py` +- Test: `tests/commons/test_local_e2e.py` + +**Interfaces:** +- Consumes: `verify_signed_pack(...)`. +- Produces: `CommonsPackDownload(pack: bytes, signature: bytes)` and `CommonsCache.refresh(download)`. + +- [ ] Update tests and doubles for paired downloads, legacy-cache rejection, anti-rollback, idempotence, equivocation, exact-model isolation, and fail-open submission. +- [ ] Run the targeted tests and observe API/behavior failures. +- [ ] Implement the paired client, one-object signed cache, source-commit format validation, and sync integration. +- [ ] Run the targeted tests to green. + +### Task 3: Immutable-snapshot release builder + +**Files:** +- Create: `scripts/build_commons_release.py` +- Test: `tests/commons/test_release_builder.py` + +**Interfaces:** +- Produces: CLI accepting a Commons repository/revision and output directory; writes `commons-pack-v1.json` and `commons-pack-v1.sig.json`. + +- [ ] Write behavior tests for untrusted-code non-execution, Git entry types, worktree mutation, poisoned JSON, contract drift, deterministic revision/content, docs-only commits, and key mismatch. +- [ ] Run builder tests and observe the missing-script failures. +- [ ] Implement bounded Git-object reads, frozen-contract parsing, deterministic pack generation, public-chain preflight, and environment-only signing. +- [ ] Run builder tests to green. + +### Task 4: Workflow, packaging, and operations documentation + +**Files:** +- Create: `.github/workflows/commons-release.yml` +- Create: `docs/commons-release-security.md` +- Modify: `pyproject.toml` +- Modify: `MANIFEST.in` +- Modify: `tests/test_packaged_schemas_v2.py` +- Create: `tests/commons/test_release_workflow.py` + +**Interfaces:** +- Consumes: release builder CLI and verifier CLI mode. +- Produces: scheduled/dispatch-only fail-closed production publication and distributable public trust data. + +- [ ] Write workflow and package-artifact behavior tests and observe failures. +- [ ] Add the workflow, public-trust package data, and concise operations/security documentation. +- [ ] Run packaging/workflow tests to green. + +### Task 5: Complete verification and runtime rebuild + +**Files:** +- Rebuild: `plugins/marginal/runtime/marginal_runtime.pyz` +- Rebuild: `plugins/marginal/runtime/provenance.json` + +**Interfaces:** +- Consumes: all implementation and tests. +- Produces: verified source tree, sdist/wheel, and deterministic Codex zipapp. + +- [ ] Run targeted Commons tests. +- [ ] Run `ruff format --check .`, `ruff check .`, `mypy src/marginal`, and `pytest -q` in `/tmp/marginal-signed-commons-venv`. +- [ ] Run `python scripts/build_codex_plugin.py` followed by `python scripts/build_codex_plugin.py --check`. +- [ ] Run `python -m build`, `python -m twine check dist/*`, and `git diff --check`. +- [ ] Inspect `git status`, `git diff --stat`, the complete diff, runtime SHA-256, and provenance without committing. diff --git a/docs/superpowers/specs/2026-08-21-signed-commons-trust-path-design.md b/docs/superpowers/specs/2026-08-21-signed-commons-trust-path-design.md new file mode 100644 index 0000000..9b5ab24 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-signed-commons-trust-path-design.md @@ -0,0 +1,81 @@ +# Signed Commons Trust Path Design + +## Purpose + +Replace MARGINAL's hardcoded Commons `source_commit` trust pin with a rotatable signed release +chain. An offline Ed25519 root certifies an online release key, and the release key signs the exact +downloaded Commons pack bytes. A verified Commons pack remains a non-authoritative, model-specific +prior and cannot affect promotion, enforcement, thresholds, or local evidence. + +## Runtime trust boundary + +MARGINAL packages the closed public root-key object and frozen Commons schemas. Runtime code uses a +small stdlib-only Ed25519 verifier that enforces RFC 8032 verification rather than permissive +ZIP-215 behavior. It strictly decodes unpadded base64url, validates canonical point encodings, +rejects small-order and non-prime-subgroup points for both the public key and `R`, requires `S < L`, +and verifies `[S]B = R + [H(R || A || M)]A`. + +The detached envelope has exactly the specified fields. Its certificate is canonicalized with +`sort_keys=True`, compact separators, `ensure_ascii=True`, and `allow_nan=False`, then verified by +the packaged root. The release key verifies the exact downloaded pack bytes. Only after those +checks pass does MARGINAL validate certificate revision bounds, the pack's internal canonical +SHA-256, its closed schema, lower-case 40-character provenance commit, and exact model registry. + +## Network and cache + +`CommonsClient.download()` performs two bounded fixed-path GET requests and returns +`CommonsPackDownload(pack: bytes, signature: bytes)`. Both requests retain the existing TLS, +resolution, monotonic-deadline, response-bound, and redacted-error behavior. No caller can select a +path. + +The cache stores a single closed JSON object containing strict base64url encodings of the exact pack +and detached-envelope bytes. The existing descriptor-relative atomic replacement makes this one +transaction-safe artifact. Legacy unsigned pack files are never read as trusted input. A candidate +is accepted only after complete verification. A higher revision replaces the cache; an identical +artifact at the same revision succeeds idempotently; equivocation at the same revision and rollback +both fail while preserving the prior valid cache. + +All download, signature, parsing, storage, and cache failures remain fail-open for local work. +Contributor mode continues processing its outbox after refresh failure. + +## Trusted release builder + +`scripts/build_commons_release.py` treats Marginal-Commons as untrusted structured Git data. It +resolves a commit, enumerates and reads only allowlisted release inputs with `git ls-tree` and +`git show`, rejects symlinks and non-regular entries, parses JSON with duplicate-key and recursion +bounds, and validates every object against MARGINAL's packaged contract. It never imports or +executes Commons code and never reads Commons `dist/`. + +The revision is the count of commits in the selected commit's history that changed the allowlisted +aggregate/lifecycle inputs. The pack's `source_commit` is the newest commit affecting those inputs, +not arbitrary repository HEAD. Thus documentation-only commits reproduce identical signed content, +while an input change advances both provenance and revision. Aggregates are sorted and duplicate +dimensions are rejected, producing deterministic bytes. + +The builder accepts the release seed only from `COMMONS_RELEASE_PRIVATE_KEY_B64URL`, strictly +decodes it, derives its public key with test/tooling-only `cryptography`, compares it with the frozen +release certificate, verifies that certificate against the packaged root using the independent +runtime verifier, and only then signs the exact final pack bytes. Secret values never enter output +or exception text. + +## Release workflow and operations + +The workflow runs only by dispatch or an approximately ten-minute schedule from trusted MARGINAL +`main`, with contents-read permission and the `commons-production` environment. It checks out +Marginal trusted code and full-history Marginal-Commons data separately, builds and independently +verifies the candidate, validates current production state, and deploys to the `marginal-commons` +Pages project with Wrangler 4.124.0 only when content changes. A missing production signature is +the one bootstrap exception. After bootstrap, invalid signed production state fails closed. + +Operations documentation covers offline-root custody, online-key rotation, compromise response, +revocation limits, bootstrap, and the distinction between fail-open runtime consumption and +fail-closed publication. Signatures authenticate a release chain; they do not make aggregate data +authoritative or prove the correctness of upstream observations. + +## Verification + +Tests use RFC 8032 vectors and `cryptography`-generated fixtures rather than self-signing with the +runtime implementation. They cover strict Ed25519 rejection, closed envelope and pack parsing, +certificate bounds, cache rollback/equivocation/idempotence, exact-model isolation, fail-open sync, +immutable Git snapshot handling, poisoned Commons input, deterministic revision/content, signing-key +mismatch, workflow contract, package inclusion, and existing privacy/enforcement invariants. diff --git a/plugins/marginal/runtime/marginal_runtime.pyz b/plugins/marginal/runtime/marginal_runtime.pyz index 0f7e04f..21ce493 100644 Binary files a/plugins/marginal/runtime/marginal_runtime.pyz and b/plugins/marginal/runtime/marginal_runtime.pyz differ diff --git a/plugins/marginal/runtime/provenance.json b/plugins/marginal/runtime/provenance.json index 652a6ab..74f75bf 100644 --- a/plugins/marginal/runtime/provenance.json +++ b/plugins/marginal/runtime/provenance.json @@ -1 +1 @@ -{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"6ac16963f223310b8ad581efff91a0139c77cedb811a761d5f531932fdadd42d","source_hash":"aeeb0eb05210f70c61f09afe3d2feb49af1e9d5b31f0eb73c4f6ba9172f12a76"} +{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"650e6eee439a2e1ad5999ded2c019964713eb0d166a40d179f5f470be7cb1842","source_hash":"bf71beb4fa69d0888d5efe077cbcac825e0e5e75d072e5a0243ded690133f285"} diff --git a/requirements/commons-release.txt b/requirements/commons-release.txt new file mode 100644 index 0000000..c693b0a --- /dev/null +++ b/requirements/commons-release.txt @@ -0,0 +1,8 @@ +# Hash-locked, binary-only dependencies for the secret-bearing Commons signing job. +cryptography==46.0.7 \ + --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ + --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b +cffi==2.0.0 \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 +pycparser==2.22 \ + --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc diff --git a/scripts/build_commons_release.py b/scripts/build_commons_release.py new file mode 100644 index 0000000..3f7858e --- /dev/null +++ b/scripts/build_commons_release.py @@ -0,0 +1,676 @@ +#!/usr/bin/env python3 +"""Build and independently verify a signed Commons release from immutable Git data.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + +_SCRIPT_ROOT = Path(__file__).resolve().parents[1] +if str(_SCRIPT_ROOT / "src") not in sys.path: + sys.path.insert(0, str(_SCRIPT_ROOT / "src")) + +from marginal.commons.cache import _parse_pack # noqa: E402 +from marginal.commons.trust import ( # noqa: E402 + CommonsTrustError, + decode_base64url_strict, + verify_release_certificate_with_root, + verify_signed_pack_with_root, +) + +MAX_SOURCE_BYTES = 2 * 1024 * 1024 +_MAX_RELEASE_BYTES = 3 * 1024 * 1024 +_TRUSTED_ROOT = _SCRIPT_ROOT +_PRIVATE_KEY_ENV = "COMMONS_RELEASE_PRIVATE_KEY_B64URL" +_PACK_NAME = "commons-pack-v1.json" +_SIGNATURE_NAME = "commons-pack-v1.sig.json" + + +class CommonsReleaseError(ValueError): + """Untrusted input or signing configuration cannot produce a release.""" + + +@dataclass(frozen=True, slots=True) +class ReleaseArtifacts: + pack: Path + signature: Path + + +@dataclass(frozen=True, slots=True) +class VerifiedRelease: + revision: int + source_commit: str + + +def _canonical(value: object) -> bytes: + try: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("utf-8") + except (TypeError, ValueError, UnicodeEncodeError, RecursionError, MemoryError, OverflowError): + raise CommonsReleaseError("release data is not canonical JSON") from None + + +def _duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CommonsReleaseError("release input contains a duplicate JSON field") + result[key] = value + return result + + +def _reject_constant(_value: str) -> None: + raise CommonsReleaseError("release input is invalid JSON") + + +def _json_bytes(raw: bytes, *, label: str) -> object: + if not raw or len(raw) > MAX_SOURCE_BYTES: + raise CommonsReleaseError(f"{label} is too large or empty") + try: + return json.loads( + raw.decode("utf-8"), + object_pairs_hook=_duplicate_keys, + parse_constant=_reject_constant, + ) + except CommonsReleaseError: + raise + except ( + UnicodeDecodeError, + json.JSONDecodeError, + RecursionError, + MemoryError, + OverflowError, + ): + raise CommonsReleaseError(f"{label} is invalid JSON") from None + + +def _mapping(value: object, *, keys: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + raise CommonsReleaseError(f"{label} has unknown or missing fields") + return value + + +def _git_environment() -> dict[str, str]: + environment = { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "LANG": "C", + "LC_ALL": "C", + "PATH": os.defpath, + } + return environment + + +class _GitSnapshot: + def __init__(self, repository: Path, revision: str) -> None: + self.repository = repository.resolve() + if not self.repository.is_dir(): + raise CommonsReleaseError("Commons repository is unavailable") + resolved = self._run("rev-parse", "--verify", f"{revision}^{{commit}}").decode().strip() + if len(resolved) != 40 or any( + character not in "0123456789abcdef" for character in resolved + ): + raise CommonsReleaseError("Commons revision is not a commit") + self.requested_commit = resolved + + def _run(self, *arguments: str) -> bytes: + try: + result = subprocess.run( + ["git", "-C", str(self.repository), *arguments], + check=False, + capture_output=True, + env=_git_environment(), + ) + except OSError: + raise CommonsReleaseError("Commons Git snapshot is unavailable") from None + if result.returncode != 0: + raise CommonsReleaseError("Commons Git snapshot is unavailable") + return result.stdout + + def _entry(self, commit: str, path: str) -> tuple[str, str, str] | None: + raw = self._run("ls-tree", "-z", commit, "--", path) + if not raw: + return None + entries = raw.rstrip(b"\0").split(b"\0") + if len(entries) != 1: + raise CommonsReleaseError(f"invalid Git entry for {path}") + metadata, separator, listed_path = entries[0].partition(b"\t") + if not separator or listed_path.decode("utf-8", "strict") != path: + raise CommonsReleaseError(f"invalid Git entry for {path}") + try: + mode, kind, object_id = metadata.decode("ascii").split(" ") + except ValueError: + raise CommonsReleaseError(f"invalid Git entry for {path}") from None + return mode, kind, object_id + + def read(self, commit: str, path: str, *, required: bool) -> bytes | None: + entry = self._entry(commit, path) + if entry is None: + if required: + raise CommonsReleaseError(f"missing release input: {path}") + return None + mode, kind, object_id = entry + if mode == "120000": + raise CommonsReleaseError(f"symlink release input: {path}") + if mode != "100644" or kind != "blob": + raise CommonsReleaseError(f"non-regular Git release input: {path}") + size_raw = self._run("cat-file", "-s", object_id) + try: + size = int(size_raw) + except ValueError: + raise CommonsReleaseError(f"invalid Git entry for {path}") from None + if size < 1 or size > MAX_SOURCE_BYTES: + raise CommonsReleaseError(f"release input is too large: {path}") + content = self._run("cat-file", "blob", object_id) + if len(content) != size: + raise CommonsReleaseError(f"invalid Git blob for {path}") + return content + + def listed_paths(self, commit: str, *roots: str) -> tuple[str, ...]: + raw = self._run("ls-tree", "-r", "-z", "--name-only", commit, "--", *roots) + if not raw: + return () + try: + return tuple(item.decode("utf-8") for item in raw.rstrip(b"\0").split(b"\0")) + except UnicodeDecodeError: + raise CommonsReleaseError("release tree contains an invalid path") from None + + def release_history(self, paths: tuple[str, ...]) -> tuple[str, ...]: + raw = self._run("rev-list", "--reverse", self.requested_commit, "--", *paths) + commits = tuple(raw.decode("ascii").splitlines()) + if not commits: + raise CommonsReleaseError("Commons release input history is empty") + if any(len(commit) != 40 for commit in commits): + raise CommonsReleaseError("Commons release input history is invalid") + return commits + + +def _trusted_bytes(relative: str) -> bytes: + path = _TRUSTED_ROOT / relative + try: + raw = path.read_bytes() + except OSError: + raise CommonsReleaseError("trusted MARGINAL release contract is unavailable") from None + if not raw or len(raw) > MAX_SOURCE_BYTES: + raise CommonsReleaseError("trusted MARGINAL release contract is invalid") + return raw + + +def _trusted_registry() -> tuple[dict[str, Any], tuple[str, ...]]: + registry = _mapping( + _json_bytes( + _trusted_bytes("models/canonical-model-registry-v1.json"), + label="trusted model registry", + ), + keys={"schema_version", "models"}, + label="trusted model registry", + ) + models = registry["models"] + if registry["schema_version"] != "1.0" or not isinstance(models, dict) or not models: + raise CommonsReleaseError("trusted model registry is invalid") + namespaces = tuple(sorted(models.values())) + if not all(isinstance(value, str) and value for value in namespaces) or len( + set(namespaces) + ) != len(namespaces): + raise CommonsReleaseError("trusted model registry is invalid") + return registry, namespaces + + +def _aggregate_contract() -> tuple[set[str], dict[str, dict[str, Any]]]: + schema = _mapping( + _json_bytes(_trusted_bytes("schemas/commons-pack-v1.json"), label="trusted pack schema"), + keys={ + "$schema", + "$id", + "title", + "type", + "required", + "properties", + "additionalProperties", + "unevaluatedProperties", + "$defs", + }, + label="trusted pack schema", + ) + definitions = schema["$defs"] + if not isinstance(definitions, dict) or not isinstance(definitions.get("aggregate"), dict): + raise CommonsReleaseError("trusted pack schema is invalid") + aggregate = definitions["aggregate"] + required = aggregate.get("required") + properties = aggregate.get("properties") + if not isinstance(required, list) or not isinstance(properties, dict): + raise CommonsReleaseError("trusted pack schema is invalid") + atom_fields = set(required) - {"lifecycle"} + if set(properties) != set(required): + raise CommonsReleaseError("trusted pack schema is invalid") + return atom_fields, properties + + +def _parse_atom( + value: object, *, atom_fields: set[str], properties: dict[str, dict[str, Any]] +) -> dict[str, object]: + atom = _mapping(value, keys=atom_fields, label="aggregate atom") + result: dict[str, object] = {} + for field in sorted(atom_fields): + candidate = atom[field] + specification = properties[field] + if "enum" in specification: + allowed = specification["enum"] + if not isinstance(candidate, str) or candidate not in allowed: + raise CommonsReleaseError(f"aggregate {field} is invalid") + else: + minimum = specification.get("minimum") + maximum = specification.get("maximum") + if ( + isinstance(candidate, bool) + or not isinstance(candidate, int) + or not isinstance(minimum, int) + or not isinstance(maximum, int) + or not minimum <= candidate <= maximum + ): + raise CommonsReleaseError(f"aggregate {field} count is invalid") + result[field] = candidate + return result + + +def _identity(atom: dict[str, object]) -> tuple[object, ...]: + return tuple(atom[field] for field in sorted(set(atom) - {"count"})) + + +def _parse_aggregate_document( + raw: bytes, + *, + namespace: str, + atom_fields: set[str], + properties: dict[str, dict[str, Any]], +) -> list[dict[str, object]]: + document = _mapping( + _json_bytes(raw, label="aggregate document"), + keys={"schema_version", "model_namespace", "atoms"}, + label="aggregate document", + ) + if document["schema_version"] != "1.0": + raise CommonsReleaseError("aggregate schema version is invalid") + if document["model_namespace"] != namespace: + raise CommonsReleaseError("aggregate namespace does not match its path") + atoms = document["atoms"] + if not isinstance(atoms, list) or not atoms or len(atoms) > 10_000: + raise CommonsReleaseError("aggregate atoms are invalid") + parsed = [_parse_atom(item, atom_fields=atom_fields, properties=properties) for item in atoms] + identities = [_identity(atom) for atom in parsed] + if len(set(identities)) != len(identities): + raise CommonsReleaseError("duplicate aggregate dimensions") + return parsed + + +def _parse_lifecycle_artifacts( + raw: bytes | None, + *, + namespaces: tuple[str, ...], + atom_fields: set[str], + properties: dict[str, dict[str, Any]], +) -> dict[str, dict[str, set[tuple[object, ...]]]]: + if raw is None: + return {} + document = _mapping( + _json_bytes(raw, label="lifecycle artifacts"), + keys={"schema_version", "models"}, + label="lifecycle artifacts", + ) + if document["schema_version"] != "1.0": + raise CommonsReleaseError("lifecycle artifacts schema version is invalid") + models = document["models"] + if not isinstance(models, dict) or set(models) != set(namespaces): + raise CommonsReleaseError("lifecycle artifact registry is invalid") + identity_fields = atom_fields - {"count"} + result: dict[str, dict[str, set[tuple[object, ...]]]] = {} + for namespace, lifecycle_value in models.items(): + lifecycle = _mapping( + lifecycle_value, + keys={"supported", "validated", "promoted"}, + label="lifecycle artifact", + ) + result[namespace] = {} + for label in ("supported", "validated", "promoted"): + values = lifecycle[label] + if not isinstance(values, list) or len(values) > 10_000: + raise CommonsReleaseError("lifecycle artifact list is invalid") + identities: list[tuple[object, ...]] = [] + for value in values: + identity_value = _mapping( + value, keys=identity_fields, label="lifecycle aggregate identity" + ) + atom = _parse_atom( + {**identity_value, "count": 1}, + atom_fields=atom_fields, + properties=properties, + ) + identities.append(_identity(atom)) + if len(set(identities)) != len(identities): + raise CommonsReleaseError("duplicate lifecycle aggregate identity") + result[namespace][label] = set(identities) + return result + + +def _lifecycle( + namespace: str, + atom: dict[str, object], + artifacts: dict[str, dict[str, set[tuple[object, ...]]]], +) -> str: + identity = _identity(atom) + state = "candidate" + namespace_artifacts = artifacts.get(namespace, {}) + for next_state in ("supported", "validated", "promoted"): + if identity not in namespace_artifacts.get(next_state, set()): + break + state = next_state + return state + + +def _release_inputs(namespaces: tuple[str, ...]) -> tuple[str, ...]: + return ( + "models/canonical-model-registry-v1.json", + "models/registry-v1.json", + *(f"models/{namespace}/aggregates.json" for namespace in namespaces), + "validation/artifacts-v1.json", + ) + + +def _reject_unknown_release_data(snapshot: _GitSnapshot, *, allowed: tuple[str, ...]) -> None: + allowed_set = set(allowed) + for path in snapshot.listed_paths(snapshot.requested_commit, "models", "validation"): + if path.endswith(".json") and path not in allowed_set: + label = "lifecycle" if path.startswith("validation/") else "model registry" + raise CommonsReleaseError(f"unreviewed {label} release input") + + +def _compile_pack(repository: Path, *, source_revision: str) -> bytes: + trusted_registry, namespaces = _trusted_registry() + atom_fields, properties = _aggregate_contract() + inputs = _release_inputs(namespaces) + snapshot = _GitSnapshot(repository, source_revision) + _reject_unknown_release_data(snapshot, allowed=inputs) + history = snapshot.release_history(inputs) + source_commit = history[-1] + canonical_registry_raw = snapshot.read( + source_commit, "models/canonical-model-registry-v1.json", required=True + ) + registry_raw = snapshot.read(source_commit, "models/registry-v1.json", required=True) + assert canonical_registry_raw is not None and registry_raw is not None + canonical_registry = _json_bytes(canonical_registry_raw, label="Commons canonical registry") + registry = _json_bytes(registry_raw, label="Commons registry") + if canonical_registry != trusted_registry or registry != trusted_registry: + raise CommonsReleaseError("Commons model registry drifted from MARGINAL") + lifecycle_raw = snapshot.read(source_commit, "validation/artifacts-v1.json", required=False) + artifacts = _parse_lifecycle_artifacts( + lifecycle_raw, + namespaces=namespaces, + atom_fields=atom_fields, + properties=properties, + ) + models: dict[str, dict[str, list[dict[str, object]]]] = {} + for namespace in namespaces: + raw = snapshot.read(source_commit, f"models/{namespace}/aggregates.json", required=False) + aggregates: list[dict[str, object]] = [] + if raw is not None: + for atom in _parse_aggregate_document( + raw, + namespace=namespace, + atom_fields=atom_fields, + properties=properties, + ): + aggregates.append({**atom, "lifecycle": _lifecycle(namespace, atom, artifacts)}) + aggregates.sort(key=_canonical) + models[namespace] = {"aggregates": aggregates} + payload: dict[str, object] = { + "schema_version": "1.0", + "source_commit": source_commit, + "commons_revision": len(history), + "compatibility": {"evidence_envelope_schema_version": "1.0"}, + "models": models, + } + payload["integrity"] = {"sha256": hashlib.sha256(_canonical(payload)).hexdigest()} + return _canonical(payload) + + +def _load_signing_material() -> tuple[Ed25519PrivateKey, dict[str, Any], dict[str, Any], bytes]: + root_document = _trusted_bytes("contracts/commons-root-key-v1.json") + certificate_document = _trusted_bytes("contracts/commons-release-key-v1.json") + certificate_signature_document = _trusted_bytes("contracts/commons-release-key-v1.sig.json") + try: + verified = verify_release_certificate_with_root( + certificate_document, certificate_signature_document, root_document + ) + encoded_seed = os.environ.get(_PRIVATE_KEY_ENV) + seed = decode_base64url_strict(encoded_seed, expected_length=32) + private_key = Ed25519PrivateKey.from_private_bytes(seed) + derived_public = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + except (CommonsTrustError, TypeError, ValueError): + raise CommonsReleaseError("release private key or public trust chain is invalid") from None + if not hmac.compare_digest(derived_public, verified.public_key): + raise CommonsReleaseError("release private key does not match public certificate") + certificate = _mapping( + _json_bytes(certificate_document, label="release certificate"), + keys={ + "schema_version", + "algorithm", + "key_id", + "public_key", + "not_before_revision", + "not_after_revision", + }, + label="release certificate", + ) + certificate_signature = _mapping( + _json_bytes(certificate_signature_document, label="certificate signature"), + keys={"schema_version", "algorithm", "key_id", "signature"}, + label="certificate signature", + ) + return private_key, certificate, certificate_signature, root_document + + +def _write_release(output_dir: Path, pack: bytes, signature: bytes) -> ReleaseArtifacts: + output_dir.mkdir(parents=True, exist_ok=True) + pack_path = output_dir / _PACK_NAME + signature_path = output_dir / _SIGNATURE_NAME + pack_path.write_bytes(pack) + signature_path.write_bytes(signature) + return ReleaseArtifacts(pack=pack_path, signature=signature_path) + + +def build_release( + commons_repository: str | Path, + *, + source_revision: str, + output_dir: str | Path, +) -> ReleaseArtifacts: + """Build, sign, independently verify, and write one deterministic release pair.""" + + private_key, certificate, certificate_signature, root_document = _load_signing_material() + pack = _compile_pack(Path(commons_repository), source_revision=source_revision) + envelope = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": certificate["key_id"], + "certificate": certificate, + "certificate_signature": certificate_signature, + "signature": base64.urlsafe_b64encode(private_key.sign(pack)).rstrip(b"=").decode("ascii"), + } + signature = _canonical(envelope) + try: + verified_certificate = verify_signed_pack_with_root(pack, signature, root_document) + parsed_pack = _parse_pack(pack) + except (CommonsTrustError, ValueError, RecursionError, MemoryError, OverflowError): + raise CommonsReleaseError("built release failed strict verification") from None + revision = parsed_pack["commons_revision"] + if ( + not verified_certificate.not_before_revision + <= revision + <= verified_certificate.not_after_revision + ): + raise CommonsReleaseError("built release revision is outside its certificate") + _independent_crypto_verify(pack, signature, root_document) + return _write_release(Path(output_dir), pack, signature) + + +def _independent_crypto_verify(pack: bytes, signature: bytes, root_document: bytes) -> None: + try: + envelope = _mapping( + _json_bytes(signature, label="detached signature"), + keys={ + "schema_version", + "algorithm", + "key_id", + "certificate", + "certificate_signature", + "signature", + }, + label="detached signature", + ) + certificate = envelope["certificate"] + certificate_signature = envelope["certificate_signature"] + assert isinstance(certificate, dict) and isinstance(certificate_signature, dict) + root = _json_bytes(root_document, label="root key") + assert isinstance(root, dict) + root_public = decode_base64url_strict(root["public_key"], expected_length=32) + release_public = decode_base64url_strict(certificate["public_key"], expected_length=32) + root_signature = decode_base64url_strict( + certificate_signature["signature"], expected_length=64 + ) + pack_signature = decode_base64url_strict(envelope["signature"], expected_length=64) + Ed25519PublicKey.from_public_bytes(root_public).verify( + root_signature, _canonical(certificate) + ) + Ed25519PublicKey.from_public_bytes(release_public).verify(pack_signature, pack) + except ( + AssertionError, + KeyError, + TypeError, + ValueError, + InvalidSignature, + CommonsTrustError, + ): + raise CommonsReleaseError("release failed independent signature verification") from None + + +def verify_release_artifacts(pack_path: str | Path, signature_path: str | Path) -> VerifiedRelease: + """Independently verify existing candidate files against MARGINAL's public root.""" + + try: + pack = Path(pack_path).read_bytes() + signature = Path(signature_path).read_bytes() + except OSError: + raise CommonsReleaseError("release artifact is unavailable") from None + if ( + not pack + or len(pack) > _MAX_RELEASE_BYTES + or not signature + or len(signature) > MAX_SOURCE_BYTES + ): + raise CommonsReleaseError("release artifact is invalid") + root_document = _trusted_bytes("contracts/commons-root-key-v1.json") + try: + certificate = verify_signed_pack_with_root(pack, signature, root_document) + parsed = _parse_pack(pack) + except (CommonsTrustError, ValueError, RecursionError, MemoryError, OverflowError): + raise CommonsReleaseError("release artifact failed strict verification") from None + revision = parsed["commons_revision"] + if not certificate.not_before_revision <= revision <= certificate.not_after_revision: + raise CommonsReleaseError("release artifact revision is outside its certificate") + _independent_crypto_verify(pack, signature, root_document) + return VerifiedRelease(revision=revision, source_commit=parsed["source_commit"]) + + +def release_required( + candidate_pack: str | Path, + candidate_signature: str | Path, + current_pack: str | Path, + current_signature: str | Path, +) -> bool: + """Fail closed on rollback/equivocation and report whether a newer release is required.""" + + candidate = verify_release_artifacts(candidate_pack, candidate_signature) + current = verify_release_artifacts(current_pack, current_signature) + if candidate.revision < current.revision: + raise CommonsReleaseError("candidate release would cause production rollback") + try: + candidate_bytes = ( + Path(candidate_pack).read_bytes(), + Path(candidate_signature).read_bytes(), + ) + current_bytes = ( + Path(current_pack).read_bytes(), + Path(current_signature).read_bytes(), + ) + except OSError: + raise CommonsReleaseError("release artifact is unavailable") from None + if candidate.revision == current.revision: + if candidate_bytes == current_bytes: + return False + raise CommonsReleaseError("candidate release conflicts with production equivocation guard") + return True + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--commons-repo", type=Path) + parser.add_argument("--revision", default="HEAD") + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--verify-pack", type=Path) + parser.add_argument("--verify-signature", type=Path) + parser.add_argument("--current-pack", type=Path) + parser.add_argument("--current-signature", type=Path) + arguments = parser.parse_args(argv) + try: + if arguments.verify_pack is not None or arguments.verify_signature is not None: + if arguments.verify_pack is None or arguments.verify_signature is None: + raise CommonsReleaseError("both release verification paths are required") + if arguments.current_pack is not None or arguments.current_signature is not None: + if arguments.current_pack is None or arguments.current_signature is None: + raise CommonsReleaseError("both current production paths are required") + required = release_required( + arguments.verify_pack, + arguments.verify_signature, + arguments.current_pack, + arguments.current_signature, + ) + print(f"deploy={'true' if required else 'false'}") + else: + verify_release_artifacts(arguments.verify_pack, arguments.verify_signature) + else: + if arguments.commons_repo is None or arguments.output_dir is None: + raise CommonsReleaseError("Commons repository and output directory are required") + build_release( + arguments.commons_repo, + source_revision=arguments.revision, + output_dir=arguments.output_dir, + ) + except CommonsReleaseError as error: + parser.error(str(error)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/marginal/adapters.py b/src/marginal/adapters.py index ec6a58a..7381890 100644 --- a/src/marginal/adapters.py +++ b/src/marginal/adapters.py @@ -243,7 +243,8 @@ def read(*names: str) -> int | None: raise TypeError("usage token fields must be integers") if value < 0: raise ValueError("usage token fields must be non-negative") - return value + normalized_value: int = value + return normalized_value return None raw_input = read("input_tokens", "prompt_tokens") or 0 @@ -349,7 +350,8 @@ def read(*names: str) -> int | None: raise TypeError("usage token fields must be integers") if value < 0: raise ValueError("usage token fields must be non-negative") - return value + normalized_value: int = value + return normalized_value return None total = read("total_tokens") diff --git a/src/marginal/commons/__init__.py b/src/marginal/commons/__init__.py index df28e5b..f341deb 100644 --- a/src/marginal/commons/__init__.py +++ b/src/marginal/commons/__init__.py @@ -1,7 +1,7 @@ """Privacy-preserving, model-specific MARGINAL Commons primitives.""" from .cache import CommonsCache, CommonsLifecycle, CommonsPrior -from .client import CommonsAck, CommonsClient +from .client import CommonsAck, CommonsClient, CommonsPackDownload from .config import CommonsConfig, CommonsMode, configure_commons_mode, load_commons_config from .evidence import CommonsEvidenceAtom, CommonsEvidenceBatch, compile_verified_evidence from .identity import ( @@ -23,6 +23,7 @@ "CommonsLifecycle", "CommonsMode", "CommonsOutbox", + "CommonsPackDownload", "CommonsPrior", "CommonsSyncResult", "OutboxEntry", diff --git a/src/marginal/commons/cache.py b/src/marginal/commons/cache.py index c56405c..5ba9cef 100644 --- a/src/marginal/commons/cache.py +++ b/src/marginal/commons/cache.py @@ -2,14 +2,18 @@ from __future__ import annotations +import base64 +import binascii import hashlib import json +import re from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any from ._storage import atomic_replace_at, locked_directory, read_bounded_at +from .client import CommonsPackDownload from .evidence import ( ActionKind, AggregateReasonCode, @@ -19,14 +23,17 @@ ValueBucket, ) from .identity import is_canonical_namespace +from .trust import verify_signed_pack -_PACK_NAME = "commons-pack-v1.json" +_CACHE_NAME = "commons-signed-cache-v1.json" _MODEL_NAMESPACES = { "openai/gpt-5.6-sol", "openai/gpt-5.6-terra", "openai/gpt-5.6-luna", } _MAX_PACK_BYTES = 2 * 1024 * 1024 +_MAX_SIGNATURE_BYTES = 64 * 1024 +_BASE64URL = re.compile(r"[A-Za-z0-9_-]+\Z") class CommonsLifecycle(str, Enum): @@ -113,9 +120,17 @@ def _parse_aggregate(namespace: str, raw: object) -> CommonsPrior: raise ValueError("Commons aggregate contains an invalid value") from exc -def _parse_pack(raw: bytes, *, expected_source_commit: str) -> dict[str, Any]: +def _reject_constant(_value: str) -> None: + raise ValueError("Commons pack contains a non-finite number") + + +def _parse_pack(raw: bytes) -> dict[str, Any]: try: - payload: Any = json.loads(raw.decode("utf-8"), object_pairs_hook=_reject_duplicate_keys) + payload: Any = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_constant, + ) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError("Commons pack is not valid JSON") from exc expected = { @@ -132,7 +147,9 @@ def _parse_pack(raw: bytes, *, expected_source_commit: str) -> dict[str, Any]: revision = payload["commons_revision"] if ( payload["schema_version"] != "1.0" - or payload["source_commit"] != expected_source_commit + or not isinstance(payload["source_commit"], str) + or len(payload["source_commit"]) != 40 + or any(character not in "0123456789abcdef" for character in payload["source_commit"]) or isinstance(revision, bool) or not isinstance(revision, int) or revision < 1 @@ -164,13 +181,84 @@ def _parse_pack(raw: bytes, *, expected_source_commit: str) -> dict[str, Any]: raise ValueError("Commons pack integrity is invalid") canonical_payload = {key: value for key, value in payload.items() if key != "integrity"} canonical = json.dumps( - canonical_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + canonical_payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, ).encode("utf-8") if not hashlib.sha256(canonical).hexdigest() == digest: raise ValueError("Commons pack integrity mismatch") return payload +def _encode_base64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _decode_base64url(value: object, *, maximum_bytes: int) -> bytes: + if ( + not isinstance(value, str) + or not value + or not _BASE64URL.fullmatch(value) + or len(value) % 4 == 1 + ): + raise ValueError("Commons signed cache is invalid") + try: + decoded = base64.b64decode(value + "=" * ((-len(value)) % 4), altchars=b"-_", validate=True) + except (ValueError, binascii.Error): + raise ValueError("Commons signed cache is invalid") from None + if len(decoded) > maximum_bytes or _encode_base64url(decoded) != value: + raise ValueError("Commons signed cache is invalid") + return decoded + + +def _cache_bytes(download: CommonsPackDownload) -> bytes: + return ( + json.dumps( + { + "schema_version": "1.0", + "pack": _encode_base64url(download.pack), + "signature": _encode_base64url(download.signature), + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + + "\n" + ).encode("ascii") + + +def _parse_cache(raw: bytes) -> CommonsPackDownload: + try: + payload: Any = json.loads( + raw.decode("ascii"), + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Commons signed cache is invalid") from exc + if not _exact_keys(payload, {"schema_version", "pack", "signature"}): + raise ValueError("Commons signed cache is invalid") + assert isinstance(payload, dict) + if payload["schema_version"] != "1.0": + raise ValueError("Commons signed cache is invalid") + return CommonsPackDownload( + pack=_decode_base64url(payload["pack"], maximum_bytes=_MAX_PACK_BYTES), + signature=_decode_base64url(payload["signature"], maximum_bytes=_MAX_SIGNATURE_BYTES), + ) + + +def _verified_download(download: CommonsPackDownload) -> dict[str, Any]: + certificate = verify_signed_pack(download.pack, download.signature) + payload = _parse_pack(download.pack) + revision = payload["commons_revision"] + if not certificate.not_before_revision <= revision <= certificate.not_after_revision: + raise ValueError("Commons pack revision is outside its certificate") + return payload + + class CommonsCache: """Persist and load only verified priors for one exact canonical model.""" @@ -179,17 +267,10 @@ def __init__( data_dir: str | Path, *, model_namespace: str, - expected_source_commit: str, max_pack_bytes: int = _MAX_PACK_BYTES, ) -> None: if not is_canonical_namespace(model_namespace): raise ValueError("Commons cache requires a canonical model namespace") - if ( - not isinstance(expected_source_commit, str) - or len(expected_source_commit) != 40 - or any(character not in "0123456789abcdef" for character in expected_source_commit) - ): - raise ValueError("Commons cache requires an exact source commit") if ( isinstance(max_pack_bytes, bool) or not isinstance(max_pack_bytes, int) @@ -200,48 +281,57 @@ def __init__( if ".." in root.parts: raise ValueError("Commons cache path must not contain traversal") self.model_namespace = model_namespace - self.expected_source_commit = expected_source_commit self.max_pack_bytes = max_pack_bytes self.path = ( - (root if root.is_absolute() else Path.cwd() / root) / "commons" / "cache" / _PACK_NAME + (root if root.is_absolute() else Path.cwd() / root) / "commons" / "cache" / _CACHE_NAME ) - def refresh(self, raw: bytes) -> bool: + def refresh(self, download: CommonsPackDownload) -> bool: """Atomically replace the cache only when every frozen-pack check succeeds.""" - if not isinstance(raw, bytes) or len(raw) > self.max_pack_bytes: + if ( + not isinstance(download, CommonsPackDownload) + or len(download.pack) > self.max_pack_bytes + or len(download.signature) > _MAX_SIGNATURE_BYTES + ): return False try: - candidate = _parse_pack(raw, expected_source_commit=self.expected_source_commit) + candidate = _verified_download(download) + candidate_cache = _cache_bytes(download) with locked_directory( self.path.parent, create=True, lock_name=".cache.lock" ) as directory: try: - existing_raw, _ = read_bounded_at( + existing_cache, _ = read_bounded_at( directory, - _PACK_NAME, - maximum_bytes=self.max_pack_bytes, + _CACHE_NAME, + maximum_bytes=(self.max_pack_bytes * 2) + _MAX_SIGNATURE_BYTES, label="Commons cache", ) except FileNotFoundError: existing = None + existing_download = None else: try: - existing = _parse_pack( - existing_raw, - expected_source_commit=self.expected_source_commit, - ) + existing_download = _parse_cache(existing_cache) + existing = _verified_download(existing_download) except (ValueError, RecursionError, MemoryError, OverflowError): existing = None + existing_download = None if ( existing is not None and candidate["commons_revision"] < existing["commons_revision"] ): return False + if ( + existing is not None + and candidate["commons_revision"] == existing["commons_revision"] + ): + return existing_download == download atomic_replace_at( directory, - _PACK_NAME, - raw, + _CACHE_NAME, + candidate_cache, temporary_prefix=".commons-pack-", label="Commons cache", ) @@ -256,11 +346,11 @@ def _load_pack(self) -> dict[str, Any] | None: ) as directory: raw, _ = read_bounded_at( directory, - _PACK_NAME, - maximum_bytes=self.max_pack_bytes, + _CACHE_NAME, + maximum_bytes=(self.max_pack_bytes * 2) + _MAX_SIGNATURE_BYTES, label="Commons cache", ) - return _parse_pack(raw, expected_source_commit=self.expected_source_commit) + return _verified_download(_parse_cache(raw)) except ( FileNotFoundError, OSError, diff --git a/src/marginal/commons/client.py b/src/marginal/commons/client.py index 31711ce..1d38001 100644 --- a/src/marginal/commons/client.py +++ b/src/marginal/commons/client.py @@ -16,6 +16,7 @@ from .outbox import OutboxEntry, _entry_boundary_valid, _reject_duplicate_keys _PACK_PATH = "/dist/commons-pack-v1.json" +_SIGNATURE_PATH = "/dist/commons-pack-v1.sig.json" _EVIDENCE_PATH = "/v1/evidence" _DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 _MAX_REQUEST_BYTES = 512 * 1024 @@ -113,10 +114,22 @@ def __post_init__(self) -> None: raise ValueError("invalid Commons ACK") +@dataclass(frozen=True, slots=True) +class CommonsPackDownload: + """Exact bytes retrieved from the two fixed Commons release paths.""" + + pack: bytes + signature: bytes + + def __post_init__(self) -> None: + if not isinstance(self.pack, bytes) or not isinstance(self.signature, bytes): + raise TypeError("Commons download requires bytes") + + class CommonsClientProtocol(Protocol): """Narrow transport boundary consumed by fail-open orchestration.""" - def download(self) -> bytes: ... + def download(self) -> CommonsPackDownload: ... def submit(self, entry: OutboxEntry) -> CommonsAck: ... @@ -335,16 +348,24 @@ def connect_resolved(*args: Any, **kwargs: Any) -> socket.socket: deadline_guard.cancel() connection.close() - def download(self) -> bytes: - """Download the pack from its fixed public path.""" + def download(self) -> CommonsPackDownload: + """Download exact pack and detached-signature bytes from fixed public paths.""" - return self._request( + pack = self._request( self._pack_origin, method="GET", path=_PACK_PATH, headers={"Accept": "application/json"}, success_status=200, ) + signature = self._request( + self._pack_origin, + method="GET", + path=_SIGNATURE_PATH, + headers={"Accept": "application/json"}, + success_status=200, + ) + return CommonsPackDownload(pack=pack, signature=signature) def submit(self, entry: OutboxEntry) -> CommonsAck: """Submit one validated envelope with retry identity only in its header.""" diff --git a/src/marginal/commons/commons-root-key-v1.json b/src/marginal/commons/commons-root-key-v1.json new file mode 100644 index 0000000..255dfa9 --- /dev/null +++ b/src/marginal/commons/commons-root-key-v1.json @@ -0,0 +1 @@ +{"algorithm":"ed25519","key_id":"commons-root-v1","public_key":"NLeHfzgR6FIun-jSoeTwKss1qJbEvFNxUNdSzWvNT1U","schema_version":"1.0"} diff --git a/src/marginal/commons/ed25519.py b/src/marginal/commons/ed25519.py new file mode 100644 index 0000000..43807a3 --- /dev/null +++ b/src/marginal/commons/ed25519.py @@ -0,0 +1,114 @@ +"""Strict dependency-free Ed25519 signature verification. + +This verifier implements the RFC 8032 verification equation with canonical encodings and +prime-order subgroup checks. It intentionally does not implement permissive ZIP-215 semantics. +""" + +from __future__ import annotations + +import hashlib + +_P = 2**255 - 19 +_L = 2**252 + 27742317777372353535851937790883648493 +_D = (-121665 * pow(121666, _P - 2, _P)) % _P +_SQRT_M1 = pow(2, (_P - 1) // 4, _P) +_IDENTITY = (0, 1, 1, 0) +_Point = tuple[int, int, int, int] + + +def _point_add(left: _Point, right: _Point) -> _Point: + x1, y1, z1, t1 = left + x2, y2, z2, t2 = right + a = ((y1 - x1) * (y2 - x2)) % _P + b = ((y1 + x1) * (y2 + x2)) % _P + c = (2 * _D * t1 * t2) % _P + d = (2 * z1 * z2) % _P + e = (b - a) % _P + f = (d - c) % _P + g = (d + c) % _P + h = (b + a) % _P + return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) + + +def _scalar_multiply(point: _Point, scalar: int) -> _Point: + result = _IDENTITY + addend = point + while scalar: + if scalar & 1: + result = _point_add(result, addend) + addend = _point_add(addend, addend) + scalar >>= 1 + return result + + +def _points_equal(left: _Point, right: _Point) -> bool: + return (left[0] * right[2] - right[0] * left[2]) % _P == 0 and ( + left[1] * right[2] - right[1] * left[2] + ) % _P == 0 + + +def _decode_point(encoded: bytes) -> _Point | None: + if len(encoded) != 32: + return None + value = int.from_bytes(encoded, "little") + sign = value >> 255 + y = value & ((1 << 255) - 1) + if y >= _P: + return None + y_squared = y * y % _P + denominator = (_D * y_squared + 1) % _P + if denominator == 0: + return None + x_squared = (y_squared - 1) * pow(denominator, _P - 2, _P) % _P + x = pow(x_squared, (_P + 3) // 8, _P) + if (x * x - x_squared) % _P != 0: + x = x * _SQRT_M1 % _P + if (x * x - x_squared) % _P != 0: + return None + if x == 0 and sign: + return None + if x & 1 != sign: + x = (-x) % _P + return (x, y, 1, x * y % _P) + + +_decoded_base_point = _decode_point(bytes.fromhex("58" + "66" * 31)) +if _decoded_base_point is None: # pragma: no cover - fixed RFC 8032 constant + raise RuntimeError("invalid Ed25519 base point") +_BASE_POINT: _Point = _decoded_base_point + + +def _strict_prime_subgroup(point: _Point) -> bool: + return not _points_equal(_scalar_multiply(point, 8), _IDENTITY) and _points_equal( + _scalar_multiply(point, _L), _IDENTITY + ) + + +def verify_ed25519(public_key: bytes, message: bytes, signature: bytes) -> bool: + """Return whether an Ed25519 signature is strict, canonical, and valid.""" + + if not isinstance(public_key, bytes) or len(public_key) != 32: + return False + if not isinstance(message, bytes): + return False + if not isinstance(signature, bytes) or len(signature) != 64: + return False + encoded_r = signature[:32] + scalar_s = int.from_bytes(signature[32:], "little") + if scalar_s >= _L: + return False + public_point = _decode_point(public_key) + point_r = _decode_point(encoded_r) + if public_point is None or point_r is None: + return False + if not _strict_prime_subgroup(public_point) or not _strict_prime_subgroup(point_r): + return False + challenge = ( + int.from_bytes(hashlib.sha512(encoded_r + public_key + message).digest(), "little") % _L + ) + left = _scalar_multiply(_BASE_POINT, scalar_s) + right = _point_add(point_r, _scalar_multiply(public_point, challenge)) + return _points_equal(left, right) + + +__all__ = ["verify_ed25519"] diff --git a/src/marginal/commons/sync.py b/src/marginal/commons/sync.py index b3f4058..9ca827d 100644 --- a/src/marginal/commons/sync.py +++ b/src/marginal/commons/sync.py @@ -76,7 +76,7 @@ def synchronize_commons( retained = 0 failures: list[SyncFailure] = [] try: - pack = client.download() + download = client.download() except CommonsHTTPError: failures.append(SyncFailure.DOWNLOAD_HTTP) except CommonsProtocolError: @@ -87,7 +87,7 @@ def synchronize_commons( failures.append(SyncFailure.DOWNLOAD_TRANSPORT) else: try: - cache_refreshed = cache.refresh(pack) + cache_refreshed = cache.refresh(download) except Exception: cache_refreshed = False if not cache_refreshed: diff --git a/src/marginal/commons/trust.py b/src/marginal/commons/trust.py new file mode 100644 index 0000000..85d3ea1 --- /dev/null +++ b/src/marginal/commons/trust.py @@ -0,0 +1,256 @@ +"""Closed parsing and verification for signed MARGINAL Commons releases.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import re +from dataclasses import dataclass +from importlib import resources +from typing import Any + +from .ed25519 import verify_ed25519 + +_ROOT_RESOURCE = "commons-root-key-v1.json" +_MAX_ROOT_BYTES = 4096 +_MAX_ENVELOPE_BYTES = 64 * 1024 +_MAX_REVISION = 2_147_483_647 +_BASE64URL = re.compile(r"[A-Za-z0-9_-]+\Z") + + +class CommonsTrustError(ValueError): + """A signed Commons artifact failed its closed trust contract.""" + + +@dataclass(frozen=True, slots=True) +class ReleaseCertificate: + """Verified release-key identity and its permitted Commons revision interval.""" + + key_id: str + public_key: bytes + not_before_revision: int + not_after_revision: int + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CommonsTrustError("invalid signed Commons artifact") + result[key] = value + return result + + +def _reject_constant(_value: str) -> None: + raise CommonsTrustError("invalid signed Commons artifact") + + +def _load_closed_json(raw: bytes, *, maximum_bytes: int) -> object: + if not isinstance(raw, bytes) or not raw or len(raw) > maximum_bytes: + raise CommonsTrustError("invalid signed Commons artifact") + try: + return json.loads( + raw.decode("utf-8"), + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_constant, + ) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + CommonsTrustError, + RecursionError, + MemoryError, + OverflowError, + ): + raise CommonsTrustError("invalid signed Commons artifact") from None + + +def _exact_mapping(value: object, keys: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + raise CommonsTrustError("invalid signed Commons artifact") + return value + + +def decode_base64url_strict(value: object, *, expected_length: int) -> bytes: + """Decode one canonical unpadded base64url string of an exact byte length.""" + + if ( + not isinstance(value, str) + or not _BASE64URL.fullmatch(value) + or "=" in value + or len(value) % 4 == 1 + ): + raise CommonsTrustError("invalid signed Commons artifact") + try: + decoded = base64.b64decode(value + "=" * ((-len(value)) % 4), altchars=b"-_", validate=True) + except (ValueError, binascii.Error): + raise CommonsTrustError("invalid signed Commons artifact") from None + canonical = base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") + if decoded.__len__() != expected_length or canonical != value: + raise CommonsTrustError("invalid signed Commons artifact") + return decoded + + +def _algorithm_header(value: dict[str, Any]) -> tuple[str, str]: + schema_version = value["schema_version"] + algorithm = value["algorithm"] + key_id = value["key_id"] + if ( + schema_version != "1.0" + or algorithm != "ed25519" + or not isinstance(key_id, str) + or not key_id + or len(key_id) > 128 + or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", key_id) + ): + raise CommonsTrustError("invalid signed Commons artifact") + return algorithm, key_id + + +def _positive_revision(value: object) -> int: + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= _MAX_REVISION: + raise CommonsTrustError("invalid signed Commons artifact") + return value + + +def _canonical_certificate(certificate: dict[str, Any]) -> bytes: + try: + return json.dumps( + certificate, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError, RecursionError, MemoryError, OverflowError): + raise CommonsTrustError("invalid signed Commons artifact") from None + + +def _parse_root(root_document: bytes) -> tuple[str, bytes]: + root = _exact_mapping( + _load_closed_json(root_document, maximum_bytes=_MAX_ROOT_BYTES), + {"schema_version", "algorithm", "key_id", "public_key"}, + ) + _root_algorithm, root_key_id = _algorithm_header(root) + return root_key_id, decode_base64url_strict(root["public_key"], expected_length=32) + + +def _parse_certificate(certificate_value: object) -> tuple[dict[str, Any], ReleaseCertificate]: + certificate = _exact_mapping( + certificate_value, + { + "schema_version", + "algorithm", + "key_id", + "public_key", + "not_before_revision", + "not_after_revision", + }, + ) + _certificate_algorithm, certificate_key_id = _algorithm_header(certificate) + public_key = decode_base64url_strict(certificate["public_key"], expected_length=32) + not_before = _positive_revision(certificate["not_before_revision"]) + not_after = _positive_revision(certificate["not_after_revision"]) + if not_before > not_after: + raise CommonsTrustError("invalid signed Commons artifact") + return certificate, ReleaseCertificate(certificate_key_id, public_key, not_before, not_after) + + +def _verify_certificate_signature( + certificate: dict[str, Any], + certificate_signature_value: object, + *, + root_key_id: str, + root_public_key: bytes, +) -> None: + certificate_signature = _exact_mapping( + certificate_signature_value, + {"schema_version", "algorithm", "key_id", "signature"}, + ) + _signature_algorithm, signature_key_id = _algorithm_header(certificate_signature) + if signature_key_id != root_key_id: + raise CommonsTrustError("invalid signed Commons artifact") + root_signature = decode_base64url_strict(certificate_signature["signature"], expected_length=64) + if not verify_ed25519(root_public_key, _canonical_certificate(certificate), root_signature): + raise CommonsTrustError("invalid signed Commons artifact") + + +def verify_release_certificate_with_root( + certificate_document: bytes, + certificate_signature_document: bytes, + root_document: bytes, +) -> ReleaseCertificate: + """Verify a closed standalone release certificate against a public root document.""" + + root_key_id, root_public_key = _parse_root(root_document) + certificate, parsed = _parse_certificate( + _load_closed_json(certificate_document, maximum_bytes=_MAX_ROOT_BYTES) + ) + certificate_signature = _load_closed_json( + certificate_signature_document, maximum_bytes=_MAX_ROOT_BYTES + ) + _verify_certificate_signature( + certificate, + certificate_signature, + root_key_id=root_key_id, + root_public_key=root_public_key, + ) + return parsed + + +def verify_signed_pack_with_root( + pack: bytes, envelope_bytes: bytes, root_document: bytes +) -> ReleaseCertificate: + """Verify a detached pack signature through an explicitly supplied public root document.""" + + if not isinstance(pack, bytes): + raise CommonsTrustError("invalid signed Commons artifact") + root_key_id, root_public_key = _parse_root(root_document) + + envelope = _exact_mapping( + _load_closed_json(envelope_bytes, maximum_bytes=_MAX_ENVELOPE_BYTES), + { + "schema_version", + "algorithm", + "key_id", + "certificate", + "certificate_signature", + "signature", + }, + ) + _envelope_algorithm, envelope_key_id = _algorithm_header(envelope) + certificate, parsed_certificate = _parse_certificate(envelope["certificate"]) + if envelope_key_id != parsed_certificate.key_id: + raise CommonsTrustError("invalid signed Commons artifact") + _verify_certificate_signature( + certificate, + envelope["certificate_signature"], + root_key_id=root_key_id, + root_public_key=root_public_key, + ) + + pack_signature = decode_base64url_strict(envelope["signature"], expected_length=64) + if not verify_ed25519(parsed_certificate.public_key, pack, pack_signature): + raise CommonsTrustError("invalid signed Commons artifact") + return parsed_certificate + + +def verify_signed_pack(pack: bytes, envelope_bytes: bytes) -> ReleaseCertificate: + """Verify a detached Commons pack using MARGINAL's packaged public root anchor.""" + + try: + root_document = resources.files("marginal.commons").joinpath(_ROOT_RESOURCE).read_bytes() + except (FileNotFoundError, OSError): + raise CommonsTrustError("invalid signed Commons artifact") from None + return verify_signed_pack_with_root(pack, envelope_bytes, root_document) + + +__all__ = [ + "CommonsTrustError", + "ReleaseCertificate", + "decode_base64url_strict", + "verify_release_certificate_with_root", + "verify_signed_pack", + "verify_signed_pack_with_root", +] diff --git a/src/marginal/integrations/codex/service.py b/src/marginal/integrations/codex/service.py index 38707b4..37c0cde 100644 --- a/src/marginal/integrations/codex/service.py +++ b/src/marginal/integrations/codex/service.py @@ -48,7 +48,6 @@ _SERVERS: dict[tuple[Path, str], tuple[SessionServer, CodexSessionRuntime]] = {} _COMMONS_PACK_ORIGIN = "https://marginal-commons.pages.dev" _COMMONS_INGRESS_ORIGIN = "https://marginal-ingress.signallayerlabs.workers.dev" -_COMMONS_SOURCE_COMMIT = "7347a1b4024329780139d17494430f2ccac94fec" @dataclass(slots=True) @@ -151,7 +150,6 @@ def _start_commons( commons.cache = CommonsCache( data_root, model_namespace=identity.namespace, - expected_source_commit=_COMMONS_SOURCE_COMMIT, ) commons.outbox = CommonsOutbox(data_root) commons.client = _commons_client() diff --git a/tests/commons/test_cache.py b/tests/commons/test_cache.py index 1f107ea..02c71fe 100644 --- a/tests/commons/test_cache.py +++ b/tests/commons/test_cache.py @@ -9,9 +9,12 @@ from pathlib import Path import pytest +from tests.commons_signing import root_document, signed_download from marginal.commons import _storage as storage_module +from marginal.commons import cache as cache_module from marginal.commons.cache import CommonsCache +from marginal.commons.trust import verify_signed_pack_with_root SOURCE_COMMIT = "a" * 40 MODEL_NAMESPACE = "openai/gpt-5.6-sol" @@ -64,17 +67,22 @@ def _pack_bytes( def _cache(tmp_path: Path) -> CommonsCache: - return CommonsCache( - tmp_path, - model_namespace=MODEL_NAMESPACE, - expected_source_commit=SOURCE_COMMIT, + return CommonsCache(tmp_path, model_namespace=MODEL_NAMESPACE) + + +@pytest.fixture(autouse=True) +def _use_test_root(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + cache_module, + "verify_signed_pack", + lambda pack, signature: verify_signed_pack_with_root(pack, signature, root_document()), ) def test_refresh_loads_only_the_selected_model_from_a_canonical_pack(tmp_path: Path) -> None: cache = _cache(tmp_path) - assert cache.refresh(_pack_bytes()) is True + assert cache.refresh(signed_download(_pack_bytes())) is True priors = cache.load_prior() assert len(priors) == 1 @@ -92,7 +100,7 @@ def test_refresh_loads_only_the_selected_model_from_a_canonical_pack(tmp_path: P b"not-json", _pack_bytes(revision=0), _pack_bytes(compatibility="2.0"), - _pack_bytes(source_commit="b" * 40), + _pack_bytes(source_commit="B" * 40), _pack_bytes(extra=("privacy-canary", "customer-acme")), ], ) @@ -100,10 +108,10 @@ def test_rejected_refresh_preserves_and_uses_the_last_valid_pack( tmp_path: Path, candidate: bytes ) -> None: cache = _cache(tmp_path) - assert cache.refresh(_pack_bytes(count=7)) is True + assert cache.refresh(signed_download(_pack_bytes(count=7))) is True before = cache.path.read_bytes() - assert cache.refresh(candidate) is False + assert cache.refresh(signed_download(candidate)) is False assert cache.path.read_bytes() == before assert [prior.count for prior in cache.load_prior()] == [7] @@ -115,7 +123,7 @@ def test_digest_is_over_canonical_payload_and_detects_post_digest_mutation(tmp_p parsed["models"][MODEL_NAMESPACE]["aggregates"][0]["count"] = 999 attacked = json.dumps(parsed, separators=(",", ":")).encode("utf-8") - assert cache.refresh(attacked) is False + assert cache.refresh(signed_download(attacked)) is False assert cache.load_prior() == () @@ -127,23 +135,23 @@ def test_refresh_rejects_oversized_and_cross_model_or_incomplete_packs(tmp_path: canonical = json.dumps(without_integrity, sort_keys=True, separators=(",", ":")).encode() missing_model["integrity"] = {"sha256": hashlib.sha256(canonical).hexdigest()} - assert cache.refresh(json.dumps(missing_model).encode()) is False - assert cache.refresh(b"{" + b" " * (cache.max_pack_bytes + 1)) is False + assert cache.refresh(signed_download(json.dumps(missing_model).encode())) is False + assert cache.refresh(signed_download(b"{" + b" " * (cache.max_pack_bytes + 1))) is False def test_refresh_rejects_recursive_json_as_a_bounded_parser_failure(tmp_path: Path) -> None: cache = _cache(tmp_path) - assert cache.refresh(("[" * 2_000 + "]" * 2_000).encode()) is False + assert cache.refresh(signed_download(("[" * 2_000 + "]" * 2_000).encode())) is False assert cache.load_prior() == () def test_refresh_rejects_revision_rollback_under_the_cache_lock(tmp_path: Path) -> None: cache = _cache(tmp_path) - assert cache.refresh(_pack_bytes(count=8, revision=2)) is True + assert cache.refresh(signed_download(_pack_bytes(count=8, revision=2))) is True before = cache.path.read_bytes() - assert cache.refresh(_pack_bytes(count=7, revision=1)) is False + assert cache.refresh(signed_download(_pack_bytes(count=7, revision=1))) is False assert cache.path.read_bytes() == before assert cache.revision == 2 @@ -155,7 +163,7 @@ def test_cache_lock_contention_returns_fail_open_within_a_bound(tmp_path: Path) import fcntl cache = _cache(tmp_path) - assert cache.refresh(_pack_bytes()) is True + assert cache.refresh(signed_download(_pack_bytes())) is True lock = (cache.path.parent / ".cache.lock").open("r+b") fcntl.flock(lock.fileno(), fcntl.LOCK_EX) @@ -166,7 +174,7 @@ def release_later() -> None: release = threading.Thread(target=release_later, daemon=True) release.start() started = time.monotonic() - refreshed = cache.refresh(_pack_bytes(count=8, revision=2)) + refreshed = cache.refresh(signed_download(_pack_bytes(count=8, revision=2))) elapsed = time.monotonic() - started release.join(timeout=1) lock.close() @@ -182,7 +190,7 @@ def test_cache_rejects_symlink_leaf_without_touching_its_target(tmp_path: Path) outside.write_bytes(b"outside") cache.path.symlink_to(outside) - assert cache.refresh(_pack_bytes()) is False + assert cache.refresh(signed_download(_pack_bytes())) is False assert outside.read_bytes() == b"outside" @@ -197,7 +205,7 @@ def short_write(descriptor: int, data: bytes) -> int: monkeypatch.setattr(storage_module.os, "write", short_write) - assert cache.refresh(_pack_bytes()) is True + assert cache.refresh(signed_download(_pack_bytes())) is True assert [prior.count for prior in cache.load_prior()] == [7] @@ -205,7 +213,7 @@ def test_partial_cache_write_failure_keeps_previous_bytes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: cache = _cache(tmp_path) - assert cache.refresh(_pack_bytes(count=7)) is True + assert cache.refresh(signed_download(_pack_bytes(count=7))) is True before = cache.path.read_bytes() def fail_after_partial_write(descriptor: int, data: bytes) -> None: @@ -214,6 +222,68 @@ def fail_after_partial_write(descriptor: int, data: bytes) -> None: monkeypatch.setattr(storage_module, "_write_all", fail_after_partial_write) - assert cache.refresh(_pack_bytes(count=8)) is False + assert cache.refresh(signed_download(_pack_bytes(count=8, revision=2))) is False assert cache.path.read_bytes() == before assert [prior.count for prior in cache.load_prior()] == [7] + + +def test_tampered_pack_or_signature_is_rejected(tmp_path: Path) -> None: + cache = _cache(tmp_path) + valid = signed_download(_pack_bytes()) + + assert cache.refresh(type(valid)(pack=valid.pack + b" ", signature=valid.signature)) is False + assert ( + cache.refresh(type(valid)(pack=valid.pack, signature=valid.signature[:-1] + b" ")) is False + ) + assert cache.load_prior() == () + + +def test_certificate_revision_bounds_are_enforced(tmp_path: Path) -> None: + cache = _cache(tmp_path) + candidate = signed_download( + _pack_bytes(revision=11), not_before_revision=1, not_after_revision=10 + ) + + assert cache.refresh(candidate) is False + assert cache.revision is None + + +def test_same_revision_requires_byte_identical_signed_artifact(tmp_path: Path) -> None: + cache = _cache(tmp_path) + original = signed_download(_pack_bytes(revision=3)) + different = signed_download(_pack_bytes(revision=3, source_commit="b" * 40)) + + assert cache.refresh(original) is True + before = cache.path.read_bytes() + assert cache.refresh(original) is True + assert cache.path.read_bytes() == before + assert cache.refresh(different) is False + assert cache.path.read_bytes() == before + + +def test_old_unsigned_cache_grants_zero_priors(tmp_path: Path) -> None: + cache = _cache(tmp_path) + legacy = cache.path.parent / "commons-pack-v1.json" + legacy.parent.mkdir(parents=True) + legacy.write_bytes(_pack_bytes()) + legacy.chmod(0o600) + + assert cache.load_prior() == () + assert cache.revision is None + + +def test_rejected_candidate_preserves_old_valid_signed_cache(tmp_path: Path) -> None: + cache = _cache(tmp_path) + old = signed_download(_pack_bytes(revision=4)) + assert cache.refresh(old) is True + before = cache.path.read_bytes() + tampered = type(old)(pack=old.pack + b" ", signature=old.signature) + + assert cache.refresh(tampered) is False + assert cache.path.read_bytes() == before + assert cache.revision == 4 + + +def test_pack_source_commit_is_provenance_not_a_hardcoded_pin(tmp_path: Path) -> None: + cache = _cache(tmp_path) + assert cache.refresh(signed_download(_pack_bytes(source_commit="b" * 40))) is True diff --git a/tests/commons/test_client.py b/tests/commons/test_client.py index c3d7144..986585d 100644 --- a/tests/commons/test_client.py +++ b/tests/commons/test_client.py @@ -15,6 +15,7 @@ from marginal.commons.client import ( CommonsClient, CommonsHTTPError, + CommonsPackDownload, CommonsProtocolError, CommonsTransportError, ) @@ -38,17 +39,19 @@ class _Handler(BaseHTTPRequestHandler): response_body: ClassVar[bytes] = b"{}" response_delay: ClassVar[float] = 0.0 trickle_delay: ClassVar[float] = 0.0 + bodies_by_path: ClassVar[dict[str, bytes]] = {} + statuses_by_path: ClassVar[dict[str, int]] = {} - def _write_body(self) -> None: + def _write_body(self, body: bytes) -> None: time.sleep(type(self).response_delay) try: if type(self).trickle_delay: - for byte in type(self).response_body: + for byte in body: time.sleep(type(self).trickle_delay) self.wfile.write(bytes([byte])) self.wfile.flush() else: - self.wfile.write(type(self).response_body) + self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): return @@ -56,10 +59,12 @@ def do_GET(self) -> None: type(self).requests.append( {"method": "GET", "path": self.path, "headers": dict(self.headers)} ) - self.send_response(type(self).response_status) - self.send_header("Content-Length", str(len(type(self).response_body))) + body = type(self).bodies_by_path.get(self.path, type(self).response_body) + status = type(self).statuses_by_path.get(self.path, type(self).response_status) + self.send_response(status) + self.send_header("Content-Length", str(len(body))) self.end_headers() - self._write_body() + self._write_body(body) def do_POST(self) -> None: length = int(self.headers.get("Content-Length", "0")) @@ -70,7 +75,7 @@ def do_POST(self) -> None: self.send_response(type(self).response_status) self.send_header("Content-Length", str(len(type(self).response_body))) self.end_headers() - self._write_body() + self._write_body(type(self).response_body) def log_message(self, _format: str, *_args: object) -> None: return @@ -83,12 +88,16 @@ def _server( body: bytes = b"{}", delay: float = 0.0, trickle_delay: float = 0.0, + bodies_by_path: dict[str, bytes] | None = None, + statuses_by_path: dict[str, int] | None = None, ) -> Iterator[str]: _Handler.requests = [] _Handler.response_status = status _Handler.response_body = body _Handler.response_delay = delay _Handler.trickle_delay = trickle_delay + _Handler.bodies_by_path = bodies_by_path or {} + _Handler.statuses_by_path = statuses_by_path or {} server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -124,18 +133,43 @@ def _entry(tmp_path: Path) -> OutboxEntry: return entry -def test_download_uses_only_the_fixed_pack_path_without_query_or_tracking_headers() -> None: - with _server(body=b"pack") as origin: +def test_download_uses_only_fixed_pack_and_signature_paths_without_tracking_headers() -> None: + with _server( + bodies_by_path={ + "/dist/commons-pack-v1.json": b"pack", + "/dist/commons-pack-v1.sig.json": b"signature", + } + ) as origin: client = CommonsClient(pack_origin=origin, ingress_origin=origin) - assert client.download() == b"pack" + assert client.download() == CommonsPackDownload(pack=b"pack", signature=b"signature") - request = _Handler.requests[0] - assert request["path"] == "/dist/commons-pack-v1.json" - headers = {key.lower(): value for key, value in request["headers"].items()} - assert "cookie" not in headers - assert "referer" not in headers - assert "x-request-id" not in headers + assert [request["path"] for request in _Handler.requests] == [ + "/dist/commons-pack-v1.json", + "/dist/commons-pack-v1.sig.json", + ] + for request in _Handler.requests: + headers = {key.lower(): value for key, value in request["headers"].items()} + assert "cookie" not in headers + assert "referer" not in headers + assert "x-request-id" not in headers + + +def test_missing_signature_is_a_redacted_fixed_path_http_failure() -> None: + with ( + _server( + bodies_by_path={"/dist/commons-pack-v1.json": b"pack"}, + statuses_by_path={"/dist/commons-pack-v1.sig.json": 404}, + ) as origin, + pytest.raises(CommonsHTTPError) as captured, + ): + CommonsClient(pack_origin=origin, ingress_origin=origin).download() + + assert captured.value.status == 404 + assert [request["path"] for request in _Handler.requests] == [ + "/dist/commons-pack-v1.json", + "/dist/commons-pack-v1.sig.json", + ] def test_submit_sends_only_the_closed_envelope_and_retry_header(tmp_path: Path) -> None: diff --git a/tests/commons/test_commons_signed_trust.py b/tests/commons/test_commons_signed_trust.py new file mode 100644 index 0000000..00d9dd5 --- /dev/null +++ b/tests/commons/test_commons_signed_trust.py @@ -0,0 +1,204 @@ +"""Independent tests for the signed Commons trust chain.""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import replace +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from marginal.commons.trust import ( + CommonsTrustError, + ReleaseCertificate, + decode_base64url_strict, + verify_release_certificate_with_root, + verify_signed_pack_with_root, +) + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _public(private: Ed25519PrivateKey) -> bytes: + return private.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + +def _canonical(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + + +def _signed_fixture( + pack: bytes = b'{"commons_revision":7}', +) -> tuple[bytes, bytes, bytes, Ed25519PrivateKey, Ed25519PrivateKey]: + root = Ed25519PrivateKey.generate() + release = Ed25519PrivateKey.generate() + certificate = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "release-test", + "public_key": _b64(_public(release)), + "not_before_revision": 1, + "not_after_revision": 10, + } + root_document = _canonical( + { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "root-test", + "public_key": _b64(_public(root)), + } + ) + envelope = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "release-test", + "certificate": certificate, + "certificate_signature": { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "root-test", + "signature": _b64(root.sign(_canonical(certificate))), + }, + "signature": _b64(release.sign(pack)), + } + return pack, _canonical(envelope), root_document, root, release + + +def test_independently_signed_chain_verifies_exact_pack_bytes() -> None: + pack, envelope, root_document, _root, _release = _signed_fixture() + + certificate = verify_signed_pack_with_root(pack, envelope, root_document) + + assert certificate == ReleaseCertificate( + key_id="release-test", + public_key=decode_base64url_strict( + json.loads(envelope)["certificate"]["public_key"], expected_length=32 + ), + not_before_revision=1, + not_after_revision=10, + ) + + +def test_packaged_production_release_certificate_verifies_against_public_root() -> None: + repository = Path(__file__).resolve().parents[2] + + certificate = verify_release_certificate_with_root( + (repository / "contracts" / "commons-release-key-v1.json").read_bytes(), + (repository / "contracts" / "commons-release-key-v1.sig.json").read_bytes(), + (repository / "contracts" / "commons-root-key-v1.json").read_bytes(), + ) + + assert certificate.key_id == "commons-release-962b690a695e079d" + assert certificate.not_before_revision == 1 + assert certificate.not_after_revision == 2_147_483_647 + + +def test_wrong_root_is_rejected() -> None: + pack, envelope, _root_document, _root, _release = _signed_fixture() + wrong_root = Ed25519PrivateKey.generate() + wrong_document = _canonical( + { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "root-test", + "public_key": _b64(_public(wrong_root)), + } + ) + + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack, envelope, wrong_document) + + +def test_forged_release_certificate_is_rejected() -> None: + pack, envelope, root_document, _root, _release = _signed_fixture() + payload = json.loads(envelope) + payload["certificate"]["not_after_revision"] = 11 + + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack, _canonical(payload), root_document) + + +def test_wrong_release_key_is_rejected() -> None: + pack, envelope, root_document, root, _release = _signed_fixture() + payload = json.loads(envelope) + payload["certificate"]["public_key"] = _b64(_public(Ed25519PrivateKey.generate())) + payload["certificate_signature"]["signature"] = _b64( + root.sign(_canonical(payload["certificate"])) + ) + + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack, _canonical(payload), root_document) + + +def test_tampered_pack_is_rejected() -> None: + pack, envelope, root_document, _root, _release = _signed_fixture() + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack + b" ", envelope, root_document) + + +@pytest.mark.parametrize( + "mutate", + [ + lambda value: value + "=", + lambda value: value[:-1] + "+", + lambda value: value + "A", + ], +) +def test_signature_base64url_is_strict_and_unpadded(mutate: object) -> None: + pack, envelope, root_document, _root, _release = _signed_fixture() + payload = json.loads(envelope) + payload["signature"] = mutate(payload["signature"]) # type: ignore[operator] + + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack, _canonical(payload), root_document) + + +@pytest.mark.parametrize("location", ["envelope", "certificate"]) +def test_closed_envelope_and_certificate_reject_extra_fields(location: str) -> None: + pack, envelope, root_document, _root, _release = _signed_fixture() + payload = json.loads(envelope) + target = payload if location == "envelope" else payload["certificate"] + target["extra"] = "not-allowed" + + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack, _canonical(payload), root_document) + + +def test_duplicate_json_fields_are_rejected() -> None: + pack, envelope, root_document, _root, _release = _signed_fixture() + duplicate = envelope[:-1] + b',"signature":"duplicate"}' + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack, duplicate, root_document) + + +def test_certificate_bounds_are_positive_ordered_integers() -> None: + pack, envelope, root_document, root, _release = _signed_fixture() + payload = json.loads(envelope) + for before, after in ((0, 10), (1, 0), (11, 10), (True, 10)): + payload["certificate"]["not_before_revision"] = before + payload["certificate"]["not_after_revision"] = after + payload["certificate_signature"]["signature"] = _b64( + root.sign(_canonical(payload["certificate"])) + ) + with pytest.raises(CommonsTrustError): + verify_signed_pack_with_root(pack, _canonical(payload), root_document) + + +def test_release_certificate_dataclass_is_immutable() -> None: + certificate = ReleaseCertificate("release", b"x" * 32, 1, 2) + with pytest.raises(AttributeError): + replace(certificate, key_id="changed").key_id = "again" # type: ignore[misc] diff --git a/tests/commons/test_ed25519.py b/tests/commons/test_ed25519.py new file mode 100644 index 0000000..f3b39d3 --- /dev/null +++ b/tests/commons/test_ed25519.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +from marginal.commons.ed25519 import verify_ed25519 + +# RFC 8032, section 7.1, test vector 1 (empty message). +RFC8032_PUBLIC_KEY = bytes.fromhex( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" +) +RFC8032_SIGNATURE = bytes.fromhex( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" +) +GROUP_ORDER = 2**252 + 27742317777372353535851937790883648493 +FIELD_PRIME = 2**255 - 19 + + +def test_rfc8032_valid_vector() -> None: + assert verify_ed25519(RFC8032_PUBLIC_KEY, b"", RFC8032_SIGNATURE) is True + + +def test_tampered_rfc8032_signature_is_rejected() -> None: + tampered = RFC8032_SIGNATURE[:-1] + bytes([RFC8032_SIGNATURE[-1] ^ 1]) + assert verify_ed25519(RFC8032_PUBLIC_KEY, b"", tampered) is False + + +@pytest.mark.parametrize( + ("public_key", "signature"), + [ + (RFC8032_PUBLIC_KEY[:-1], RFC8032_SIGNATURE), + (RFC8032_PUBLIC_KEY, RFC8032_SIGNATURE[:-1]), + (RFC8032_PUBLIC_KEY + b"\0", RFC8032_SIGNATURE), + (RFC8032_PUBLIC_KEY, RFC8032_SIGNATURE + b"\0"), + ], +) +def test_wrong_key_or_signature_length_is_rejected(public_key: bytes, signature: bytes) -> None: + assert verify_ed25519(public_key, b"", signature) is False + + +def test_noncanonical_public_point_encoding_is_rejected() -> None: + encoded_y_equal_to_p = FIELD_PRIME.to_bytes(32, "little") + assert verify_ed25519(encoded_y_equal_to_p, b"", RFC8032_SIGNATURE) is False + + +def test_malformed_public_point_is_rejected() -> None: + # y=2 has no corresponding x on Edwards25519. + malformed = (2).to_bytes(32, "little") + assert verify_ed25519(malformed, b"", RFC8032_SIGNATURE) is False + + +def test_small_order_public_key_is_rejected() -> None: + identity = (1).to_bytes(32, "little") + assert verify_ed25519(identity, b"", RFC8032_SIGNATURE) is False + + +def test_invalid_subgroup_public_key_is_rejected() -> None: + # Add the order-2 point (0, -1) to the RFC public point. This remains a valid, + # non-small-order curve point but is outside the prime-order subgroup. + encoded_y = int.from_bytes(RFC8032_PUBLIC_KEY, "little") + y = encoded_y & ((1 << 255) - 1) + invalid_subgroup_y = (-y) % FIELD_PRIME + invalid_subgroup = invalid_subgroup_y.to_bytes(32, "little") + invalid_subgroup = invalid_subgroup[:-1] + bytes( + [invalid_subgroup[-1] | (RFC8032_PUBLIC_KEY[-1] & 0x80)] + ) + assert verify_ed25519(invalid_subgroup, b"", RFC8032_SIGNATURE) is False + + +def test_malformed_r_is_rejected() -> None: + malformed_r = FIELD_PRIME.to_bytes(32, "little") + assert verify_ed25519(RFC8032_PUBLIC_KEY, b"", malformed_r + RFC8032_SIGNATURE[32:]) is False + + +def test_small_order_r_is_rejected() -> None: + identity_r = (1).to_bytes(32, "little") + assert verify_ed25519(RFC8032_PUBLIC_KEY, b"", identity_r + RFC8032_SIGNATURE[32:]) is False + + +def test_s_at_or_above_group_order_is_rejected() -> None: + signature = RFC8032_SIGNATURE[:32] + GROUP_ORDER.to_bytes(32, "little") + assert verify_ed25519(RFC8032_PUBLIC_KEY, b"", signature) is False + + +def test_tampered_message_is_rejected() -> None: + assert verify_ed25519(RFC8032_PUBLIC_KEY, b"tampered", RFC8032_SIGNATURE) is False diff --git a/tests/commons/test_local_e2e.py b/tests/commons/test_local_e2e.py index 5be984d..0702b6b 100644 --- a/tests/commons/test_local_e2e.py +++ b/tests/commons/test_local_e2e.py @@ -6,10 +6,14 @@ from dataclasses import replace from pathlib import Path +from tests.commons_signing import root_document, signed_download + import marginal.integrations.codex.service as service_module -from marginal.commons.client import CommonsAck +from marginal.commons import cache as cache_module +from marginal.commons.client import CommonsAck, CommonsPackDownload from marginal.commons.config import CommonsMode, configure_commons_mode from marginal.commons.outbox import CommonsOutbox, OutboxEntry +from marginal.commons.trust import verify_signed_pack_with_root from marginal.integrations.codex.events import SessionEvent from marginal.integrations.codex.identity import current_promotion_identity from marginal.integrations.codex.service import ( @@ -61,8 +65,8 @@ def _write_pack(self) -> None: self.root.mkdir(parents=True, exist_ok=True) (self.root / "commons-pack-v1.json").write_bytes(_pack(self.models, revision=self.revision)) - def download(self) -> bytes: - return (self.root / "commons-pack-v1.json").read_bytes() + def download(self) -> CommonsPackDownload: + return signed_download((self.root / "commons-pack-v1.json").read_bytes()) def submit(self, entry: OutboxEntry) -> CommonsAck: envelope = json.loads(entry.body_bytes) @@ -103,6 +107,11 @@ def test_local_lifecycle_round_trip_is_model_isolated_private_and_non_authoritat data = tmp_path / "plugin-data" boundary = _LocalIngressCommonsAdapter(tmp_path / "local-boundary") + monkeypatch.setattr( + cache_module, + "verify_signed_pack", + lambda pack, signature: verify_signed_pack_with_root(pack, signature, root_document()), + ) configure_commons_mode(data, mode=CommonsMode.CONTRIBUTOR) monkeypatch.setattr(service_module, "_commons_client", lambda: boundary) start = SessionEvent( diff --git a/tests/commons/test_release_builder.py b/tests/commons/test_release_builder.py new file mode 100644 index 0000000..b3328d3 --- /dev/null +++ b/tests/commons/test_release_builder.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from scripts import build_commons_release as builder +from tests.commons_signing import ( + RELEASE_PRIVATE, + ROOT_PRIVATE, + b64url, + canonical, + pack_bytes, + public_bytes, + signed_download, +) + +REPOSITORY = Path(__file__).resolve().parents[2] +NAMESPACE = "openai/gpt-5.6-sol" + + +def _git(repo: Path, *arguments: str) -> str: + result = subprocess.run( + ["git", *arguments], cwd=repo, check=True, capture_output=True, text=True + ) + return result.stdout.strip() + + +def _write(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(canonical(value)) + + +def _commit(repo: Path, message: str) -> str: + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", message) + return _git(repo, "rev-parse", "HEAD") + + +def _atom(*, count: object = 7) -> dict[str, object]: + return { + "record_type": "decision", + "action_kind": "test", + "cost_bucket": "low", + "gain_bucket": "high", + "recommendation": "allow", + "applied_decision": "allow", + "reason_code": "APPROVED", + "outcome_class": "not_applicable", + "count": count, + "minimum_group_size": 5, + } + + +@pytest.fixture +def commons_repo(tmp_path: Path) -> Path: + repo = tmp_path / "commons-data" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test User") + registry = json.loads((REPOSITORY / "models" / "canonical-model-registry-v1.json").read_text()) + _write(repo / "models" / "canonical-model-registry-v1.json", registry) + _write(repo / "models" / "registry-v1.json", registry) + _commit(repo, "initial release data") + return repo + + +@pytest.fixture(autouse=True) +def trusted_test_contracts(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + trusted = tmp_path / "trusted-marginal" + (trusted / "contracts").mkdir(parents=True) + (trusted / "models").mkdir(parents=True) + (trusted / "schemas").mkdir(parents=True) + for relative in ( + "models/canonical-model-registry-v1.json", + "schemas/commons-pack-v1.json", + ): + target = trusted / relative + target.write_bytes((REPOSITORY / relative).read_bytes()) + certificate = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "test-release", + "public_key": b64url(public_bytes(RELEASE_PRIVATE)), + "not_before_revision": 1, + "not_after_revision": 2_147_483_647, + } + root = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "test-root", + "public_key": b64url(public_bytes(ROOT_PRIVATE)), + } + certificate_signature = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "test-root", + "signature": b64url(ROOT_PRIVATE.sign(canonical(certificate))), + } + _write(trusted / "contracts" / "commons-root-key-v1.json", root) + _write(trusted / "contracts" / "commons-release-key-v1.json", certificate) + _write( + trusted / "contracts" / "commons-release-key-v1.sig.json", + certificate_signature, + ) + monkeypatch.setattr(builder, "_TRUSTED_ROOT", trusted) + seed = RELEASE_PRIVATE.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + monkeypatch.setenv("COMMONS_RELEASE_PRIVATE_KEY_B64URL", b64url(seed)) + return trusted + + +def _build(repo: Path, output: Path, revision: str = "HEAD") -> builder.ReleaseArtifacts: + return builder.build_release(repo, source_revision=revision, output_dir=output) + + +def test_builder_never_executes_untrusted_commons_code(commons_repo: Path, tmp_path: Path) -> None: + marker = tmp_path / "executed" + script = commons_repo / "tooling" / "build_pack.py" + script.parent.mkdir() + script.write_text(f"from pathlib import Path\nPath({str(marker)!r}).write_text('bad')\n") + _commit(commons_repo, "malicious tooling") + + _build(commons_repo, tmp_path / "out") + + assert not marker.exists() + + +def test_symlink_git_input_is_rejected(commons_repo: Path, tmp_path: Path) -> None: + registry = commons_repo / "models" / "registry-v1.json" + registry.unlink() + registry.symlink_to("canonical-model-registry-v1.json") + _commit(commons_repo, "symlink registry") + + with pytest.raises(builder.CommonsReleaseError, match="symlink"): + _build(commons_repo, tmp_path / "out") + + +def test_worktree_mutation_cannot_alter_claimed_snapshot( + commons_repo: Path, tmp_path: Path +) -> None: + claimed = _git(commons_repo, "rev-parse", "HEAD") + aggregate = commons_repo / "models" / NAMESPACE / "aggregates.json" + _write( + aggregate, + {"schema_version": "1.0", "model_namespace": NAMESPACE, "atoms": [_atom()]}, + ) + + artifacts = _build(commons_repo, tmp_path / "out", claimed) + pack = json.loads(artifacts.pack.read_bytes()) + + assert pack["models"][NAMESPACE]["aggregates"] == [] + assert pack["source_commit"] == claimed + + +def test_docs_only_commit_produces_identical_signed_release( + commons_repo: Path, tmp_path: Path +) -> None: + first = _build(commons_repo, tmp_path / "first") + (commons_repo / "README.md").write_text("documentation only\n") + _commit(commons_repo, "docs only") + + second = _build(commons_repo, tmp_path / "second") + + assert first.pack.read_bytes() == second.pack.read_bytes() + assert first.signature.read_bytes() == second.signature.read_bytes() + + +def test_changed_aggregate_advances_revision_and_source_commit( + commons_repo: Path, tmp_path: Path +) -> None: + first = _build(commons_repo, tmp_path / "first") + aggregate = commons_repo / "models" / NAMESPACE / "aggregates.json" + _write( + aggregate, + {"schema_version": "1.0", "model_namespace": NAMESPACE, "atoms": [_atom()]}, + ) + changed_commit = _commit(commons_repo, "aggregate") + + second = _build(commons_repo, tmp_path / "second") + first_pack = json.loads(first.pack.read_bytes()) + second_pack = json.loads(second.pack.read_bytes()) + + assert second_pack["commons_revision"] == first_pack["commons_revision"] + 1 + assert second_pack["source_commit"] == changed_commit + + +def test_changed_lifecycle_source_advances_revision(commons_repo: Path, tmp_path: Path) -> None: + aggregate = commons_repo / "models" / NAMESPACE / "aggregates.json" + _write( + aggregate, + {"schema_version": "1.0", "model_namespace": NAMESPACE, "atoms": [_atom()]}, + ) + _commit(commons_repo, "aggregate") + first = _build(commons_repo, tmp_path / "first") + identity = {key: value for key, value in _atom().items() if key != "count"} + namespaces = json.loads( + (REPOSITORY / "models" / "canonical-model-registry-v1.json").read_text() + )["models"].values() + lifecycle = { + "schema_version": "1.0", + "models": { + namespace: { + "supported": [identity] if namespace == NAMESPACE else [], + "validated": [], + "promoted": [], + } + for namespace in namespaces + }, + } + _write(commons_repo / "validation" / "artifacts-v1.json", lifecycle) + lifecycle_commit = _commit(commons_repo, "lifecycle") + + second = _build(commons_repo, tmp_path / "second") + first_pack = json.loads(first.pack.read_bytes()) + second_pack = json.loads(second.pack.read_bytes()) + + assert second_pack["commons_revision"] == first_pack["commons_revision"] + 1 + assert second_pack["source_commit"] == lifecycle_commit + assert second_pack["models"][NAMESPACE]["aggregates"][0]["lifecycle"] == "supported" + + +def test_deterministic_repeated_build_is_byte_identical(commons_repo: Path, tmp_path: Path) -> None: + first = _build(commons_repo, tmp_path / "first") + second = _build(commons_repo, tmp_path / "second") + assert first.pack.read_bytes() == second.pack.read_bytes() + assert first.signature.read_bytes() == second.signature.read_bytes() + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda value: value.update({"unknown": "free text"}), "unknown"), + (lambda value: value["atoms"][0].update({"count": 0}), "count"), + ( + lambda value: value.update({"model_namespace": "openai/gpt-5.6-terra"}), + "namespace", + ), + (lambda value: value["atoms"].append(dict(value["atoms"][0])), "duplicate"), + ], +) +def test_poisoned_aggregate_data_is_rejected( + commons_repo: Path, tmp_path: Path, mutation: object, message: str +) -> None: + value = {"schema_version": "1.0", "model_namespace": NAMESPACE, "atoms": [_atom()]} + mutation(value) # type: ignore[operator] + _write(commons_repo / "models" / NAMESPACE / "aggregates.json", value) + _commit(commons_repo, "poison") + + with pytest.raises(builder.CommonsReleaseError, match=message): + _build(commons_repo, tmp_path / "out") + + +def test_duplicate_json_keys_and_recursive_or_oversized_input_are_rejected( + commons_repo: Path, tmp_path: Path +) -> None: + aggregate = commons_repo / "models" / NAMESPACE / "aggregates.json" + aggregate.parent.mkdir(parents=True) + aggregate.write_bytes( + b'{"schema_version":"1.0","schema_version":"1.0",' + b'"model_namespace":"openai/gpt-5.6-sol","atoms":[]}' + ) + _commit(commons_repo, "duplicate json") + with pytest.raises(builder.CommonsReleaseError, match="duplicate"): + _build(commons_repo, tmp_path / "duplicate") + + aggregate.write_bytes(b"[" * 2_000 + b"]" * 2_000) + _commit(commons_repo, "recursive json") + with pytest.raises(builder.CommonsReleaseError): + _build(commons_repo, tmp_path / "recursive") + + aggregate.write_bytes(b" " * (builder.MAX_SOURCE_BYTES + 1)) + _commit(commons_repo, "oversized json") + with pytest.raises(builder.CommonsReleaseError, match="large"): + _build(commons_repo, tmp_path / "oversized") + + +def test_registry_drift_and_extra_lifecycle_artifacts_are_rejected( + commons_repo: Path, tmp_path: Path +) -> None: + registry = json.loads((commons_repo / "models" / "registry-v1.json").read_text()) + registry["models"]["custom"] = "custom/model" + _write(commons_repo / "models" / "registry-v1.json", registry) + _commit(commons_repo, "registry drift") + with pytest.raises(builder.CommonsReleaseError, match="registry"): + _build(commons_repo, tmp_path / "registry") + + _write( + commons_repo / "models" / "registry-v1.json", + json.loads((REPOSITORY / "models" / "canonical-model-registry-v1.json").read_text()), + ) + _write(commons_repo / "validation" / "unreviewed-lifecycle.json", {}) + _commit(commons_repo, "extra lifecycle artifact") + with pytest.raises(builder.CommonsReleaseError, match="lifecycle"): + _build(commons_repo, tmp_path / "lifecycle") + + (commons_repo / "validation" / "unreviewed-lifecycle.json").unlink() + marker = tmp_path / "validation-code-executed" + (commons_repo / "validation" / "lifecycle.py").write_text( + f"from pathlib import Path\nPath({str(marker)!r}).write_text('bad')\n" + ) + _commit(commons_repo, "untrusted validation code") + + _build(commons_repo, tmp_path / "untrusted-validation-code") + + assert not marker.exists() + + +def test_private_key_public_certificate_mismatch_fails_before_writing( + commons_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv( + "COMMONS_RELEASE_PRIVATE_KEY_B64URL", + b64url(bytes(reversed(range(32)))), + ) + output = tmp_path / "out" + + with pytest.raises(builder.CommonsReleaseError, match="does not match") as captured: + _build(commons_repo, output) + + assert not output.exists() + assert "COMMONS_RELEASE_PRIVATE_KEY_B64URL" not in str(captured.value) + + +def test_private_key_must_be_strict_unpadded_base64url_from_environment( + commons_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("COMMONS_RELEASE_PRIVATE_KEY_B64URL", b64url(bytes(range(32))) + "=") + with pytest.raises(builder.CommonsReleaseError, match="private key"): + _build(commons_repo, tmp_path / "out") + + +def test_built_release_is_independently_verified(commons_repo: Path, tmp_path: Path) -> None: + artifacts = _build(commons_repo, tmp_path / "out") + verified = builder.verify_release_artifacts(artifacts.pack, artifacts.signature) + assert verified.revision == 1 + + +def test_production_comparison_is_idempotent_and_anti_rollback( + commons_repo: Path, tmp_path: Path +) -> None: + current = _build(commons_repo, tmp_path / "current") + assert ( + builder.release_required(current.pack, current.signature, current.pack, current.signature) + is False + ) + aggregate = commons_repo / "models" / NAMESPACE / "aggregates.json" + _write( + aggregate, + {"schema_version": "1.0", "model_namespace": NAMESPACE, "atoms": [_atom()]}, + ) + _commit(commons_repo, "aggregate") + candidate = _build(commons_repo, tmp_path / "candidate") + assert ( + builder.release_required( + candidate.pack, candidate.signature, current.pack, current.signature + ) + is True + ) + with pytest.raises(builder.CommonsReleaseError, match="rollback"): + builder.release_required( + current.pack, current.signature, candidate.pack, candidate.signature + ) + + +def test_production_comparison_fails_closed_for_equivocation_or_invalid_current( + commons_repo: Path, tmp_path: Path +) -> None: + candidate = _build(commons_repo, tmp_path / "candidate") + parsed = json.loads(candidate.pack.read_bytes()) + conflicting_pack = pack_bytes( + revision=parsed["commons_revision"], + source_commit=parsed["source_commit"], + models={NAMESPACE: [{**_atom(), "lifecycle": "candidate"}]}, + ) + conflict = signed_download(conflicting_pack) + conflict_pack_path = tmp_path / "conflict-pack.json" + conflict_signature_path = tmp_path / "conflict-signature.json" + conflict_pack_path.write_bytes(conflict.pack) + conflict_signature_path.write_bytes(conflict.signature) + + with pytest.raises(builder.CommonsReleaseError, match="equivocation"): + builder.release_required( + conflict_pack_path, + conflict_signature_path, + candidate.pack, + candidate.signature, + ) + + invalid_signature = tmp_path / "invalid-signature.json" + invalid_signature.write_bytes(candidate.signature.read_bytes() + b" ") + with pytest.raises(builder.CommonsReleaseError): + builder.release_required( + candidate.pack, + candidate.signature, + candidate.pack, + invalid_signature, + ) diff --git a/tests/commons/test_release_workflow.py b/tests/commons/test_release_workflow.py new file mode 100644 index 0000000..7847038 --- /dev/null +++ b/tests/commons/test_release_workflow.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "commons-release.yml" + + +def _workflow() -> dict[str, object]: + return yaml.load(WORKFLOW.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +def test_release_workflow_has_only_dispatch_and_ten_minute_schedule() -> None: + workflow = _workflow() + triggers = workflow["on"] + assert set(triggers) == {"workflow_dispatch", "schedule"} + assert triggers["schedule"] == [{"cron": "*/10 * * * *"}] + assert workflow["permissions"] == {"contents": "read"} + + +def test_release_workflow_uses_trusted_main_environment_and_data_checkout() -> None: + workflow = _workflow() + release = workflow["jobs"]["release"] + assert release["environment"] == "commons-production" + assert "github.repository == 'SignalLayerLabs/Marginal'" in release["if"] + assert "github.ref == 'refs/heads/main'" in release["if"] + steps = release["steps"] + checkout = next(step for step in steps if step.get("name") == "Checkout trusted MARGINAL") + assert checkout["with"] == {"path": "marginal", "persist-credentials": "false"} + commons = next(step for step in steps if step.get("name") == "Checkout Commons as data") + assert commons["with"] == { + "repository": "SignalLayerLabs/Marginal-Commons", + "ref": "main", + "fetch-depth": "0", + "path": "commons-data", + "persist-credentials": "false", + } + + +def test_release_workflow_verifies_current_state_and_pins_pages_deploy() -> None: + workflow = _workflow() + steps = workflow["jobs"]["release"]["steps"] + combined = "\n".join(str(step.get("run", "")) for step in steps) + assert "commons-pack-v1.json" in combined + assert "commons-pack-v1.sig.json" in combined + assert "--verify-pack" in combined + assert "--current-pack" in combined + assert "npx wrangler@4.124.0 pages deploy" in combined + assert "--project-name marginal-commons" in combined + assert "--branch main" not in combined + deploy = next(step for step in steps if step.get("name") == "Deploy signed release") + assert deploy["env"] == { + "CLOUDFLARE_API_TOKEN": "${{ secrets.CLOUDFLARE_API_TOKEN }}", + "CLOUDFLARE_ACCOUNT_ID": "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}", + } + + +def test_release_workflow_exposes_only_required_release_secrets() -> None: + workflow = _workflow() + steps = workflow["jobs"]["release"]["steps"] + build = next(step for step in steps if step.get("name") == "Build signed candidate") + assert build["env"] == { + "COMMONS_RELEASE_PRIVATE_KEY_B64URL": ("${{ secrets.COMMONS_RELEASE_PRIVATE_KEY_B64URL }}") + } + + +def test_release_workflow_pins_every_action_and_hash_locks_signing_dependencies() -> None: + workflow = _workflow() + steps = workflow["jobs"]["release"]["steps"] + action_reference = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}\Z") + used_actions = [step["uses"] for step in steps if "uses" in step] + assert used_actions + assert all(action_reference.fullmatch(reference) for reference in used_actions) + + install = next( + step for step in steps if step.get("name") == "Install trusted release dependencies" + ) + command = install["run"] + assert "--require-hashes" in command + assert "--only-binary=:all:" in command + assert "requirements/commons-release.txt" in command + + requirements = (ROOT / "requirements" / "commons-release.txt").read_text(encoding="utf-8") + assert "cryptography==" in requirements + assert "cffi==" in requirements + assert "pycparser==" in requirements + assert ">=" not in requirements + assert requirements.count("--hash=sha256:") >= 4 + + +def test_unsigned_bootstrap_checks_all_production_deployment_pages() -> None: + workflow = _workflow() + steps = workflow["jobs"]["release"]["steps"] + compare = next( + step for step in steps if step.get("name") == "Compare current signed production state" + ) + command = compare["run"] + assert "page=${deployment_page}" in command + assert "total_pages" in command + assert "deployment_page=$((deployment_page + 1))" in command diff --git a/tests/commons/test_sync.py b/tests/commons/test_sync.py index e65985f..cd4e68e 100644 --- a/tests/commons/test_sync.py +++ b/tests/commons/test_sync.py @@ -8,10 +8,17 @@ from pathlib import Path import pytest +from tests.commons_signing import root_document, signed_download import marginal.commons as commons +from marginal.commons import cache as cache_module from marginal.commons.cache import CommonsCache -from marginal.commons.client import CommonsAck, CommonsHTTPError, CommonsProtocolError +from marginal.commons.client import ( + CommonsAck, + CommonsHTTPError, + CommonsPackDownload, + CommonsProtocolError, +) from marginal.commons.config import CommonsConfig, CommonsMode from marginal.commons.evidence import ( ActionKind, @@ -26,6 +33,7 @@ from marginal.commons.identity import resolve_canonical_model from marginal.commons.outbox import CommonsOutbox, OutboxEntry from marginal.commons.sync import SyncFailure, synchronize_commons +from marginal.commons.trust import verify_signed_pack_with_root MODEL_NAMESPACE = "openai/gpt-5.6-sol" SOURCE_COMMIT = "a" * 40 @@ -49,13 +57,15 @@ def _pack_bytes() -> bytes: class _RecordingClient: - def __init__(self, *, pack: bytes | Exception, submit: CommonsAck | Exception) -> None: + def __init__( + self, *, pack: CommonsPackDownload | Exception, submit: CommonsAck | Exception + ) -> None: self.pack = pack self.submit_result = submit self.download_calls = 0 self.submitted: list[OutboxEntry] = [] - def download(self) -> bytes: + def download(self) -> CommonsPackDownload: self.download_calls += 1 if isinstance(self.pack, Exception): raise self.pack @@ -94,15 +104,24 @@ def _batch(model: str = "gpt-5.6-sol") -> CommonsEvidenceBatch: def _components(tmp_path: Path) -> tuple[CommonsCache, CommonsOutbox]: return ( - CommonsCache( - tmp_path, - model_namespace=MODEL_NAMESPACE, - expected_source_commit=SOURCE_COMMIT, - ), + CommonsCache(tmp_path, model_namespace=MODEL_NAMESPACE), CommonsOutbox(tmp_path), ) +@pytest.fixture(autouse=True) +def _use_test_root(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + cache_module, + "verify_signed_pack", + lambda pack, signature: verify_signed_pack_with_root(pack, signature, root_document()), + ) + + +def _download() -> CommonsPackDownload: + return signed_download(_pack_bytes()) + + def test_task_five_interfaces_are_available_from_the_commons_package() -> None: assert commons.CommonsCache is CommonsCache assert commons.CommonsOutbox is CommonsOutbox @@ -133,7 +152,7 @@ def test_local_only_makes_zero_network_calls_and_does_not_enqueue(tmp_path: Path def test_read_only_downloads_but_never_enqueues_or_submits(tmp_path: Path) -> None: cache, outbox = _components(tmp_path) - client = _RecordingClient(pack=_pack_bytes(), submit=AssertionError("submit must not run")) + client = _RecordingClient(pack=_download(), submit=AssertionError("submit must not run")) result = synchronize_commons( CommonsConfig(CommonsMode.READ_ONLY), @@ -151,7 +170,7 @@ def test_read_only_downloads_but_never_enqueues_or_submits(tmp_path: Path) -> No def test_contributor_without_new_or_queued_evidence_only_downloads(tmp_path: Path) -> None: cache, outbox = _components(tmp_path) - client = _RecordingClient(pack=_pack_bytes(), submit=AssertionError("submit must not run")) + client = _RecordingClient(pack=_download(), submit=AssertionError("submit must not run")) result = synchronize_commons( CommonsConfig(CommonsMode.CONTRIBUTOR), @@ -168,7 +187,7 @@ def test_contributor_without_new_or_queued_evidence_only_downloads(tmp_path: Pat def test_contributor_cannot_relabel_a_model_bound_batch_for_another_cache(tmp_path: Path) -> None: cache, outbox = _components(tmp_path) - client = _RecordingClient(pack=_pack_bytes(), submit=AssertionError("submit must not run")) + client = _RecordingClient(pack=_download(), submit=AssertionError("submit must not run")) result = synchronize_commons( CommonsConfig(CommonsMode.CONTRIBUTOR), @@ -187,7 +206,7 @@ def test_sync_does_not_submit_a_queued_envelope_for_another_cache_model(tmp_path cache, outbox = _components(tmp_path) queued = outbox.enqueue(batch=_batch("gpt-5.6-terra")) assert queued is not None - client = _RecordingClient(pack=_pack_bytes(), submit=AssertionError("submit must not run")) + client = _RecordingClient(pack=_download(), submit=AssertionError("submit must not run")) result = synchronize_commons( CommonsConfig(CommonsMode.CONTRIBUTOR), @@ -204,7 +223,7 @@ def test_sync_does_not_submit_a_queued_envelope_for_another_cache_model(tmp_path def test_contributor_ack_deletes_queued_evidence(tmp_path: Path) -> None: cache, outbox = _components(tmp_path) - client = _RecordingClient(pack=_pack_bytes(), submit=CommonsAck(True, False)) + client = _RecordingClient(pack=_download(), submit=CommonsAck(True, False)) result = synchronize_commons( CommonsConfig(CommonsMode.CONTRIBUTOR), @@ -224,7 +243,7 @@ def test_4xx_quarantines_but_5xx_and_protocol_failures_retain_for_retry(tmp_path case = tmp_path / str(status) cache, outbox = _components(case) outbox.enqueue(batch=_batch()) - client = _RecordingClient(pack=_pack_bytes(), submit=CommonsHTTPError(status=status)) + client = _RecordingClient(pack=_download(), submit=CommonsHTTPError(status=status)) result = synchronize_commons( CommonsConfig(CommonsMode.CONTRIBUTOR), @@ -245,7 +264,7 @@ def test_4xx_quarantines_but_5xx_and_protocol_failures_retain_for_retry(tmp_path cache=cache, outbox=outbox, client=_RecordingClient( - pack=_pack_bytes(), submit=CommonsProtocolError("invalid Commons response") + pack=_download(), submit=CommonsProtocolError("invalid Commons response") ), ) assert result.retained == 1 @@ -256,7 +275,7 @@ def test_unvalidated_ack_object_never_deletes_queued_evidence(tmp_path: Path) -> cache, outbox = _components(tmp_path) queued = outbox.enqueue(batch=_batch()) assert queued is not None - client = _RecordingClient(pack=_pack_bytes(), submit=object()) # type: ignore[arg-type] + client = _RecordingClient(pack=_download(), submit=object()) # type: ignore[arg-type] result = synchronize_commons( CommonsConfig(CommonsMode.CONTRIBUTOR), @@ -295,6 +314,27 @@ def test_sync_is_bounded_and_download_failure_does_not_block_outbox_retry(tmp_pa assert "privacy-canary" not in repr(result) +def test_contributor_submission_proceeds_after_pack_verification_failure(tmp_path: Path) -> None: + cache, outbox = _components(tmp_path) + queued = outbox.enqueue(batch=_batch()) + assert queued is not None + valid = _download() + tampered = CommonsPackDownload(pack=valid.pack + b" ", signature=valid.signature) + client = _RecordingClient(pack=tampered, submit=CommonsAck(True, False)) + + result = synchronize_commons( + CommonsConfig(CommonsMode.CONTRIBUTOR), + cache=cache, + outbox=outbox, + client=client, + ) + + assert result.cache_refreshed is False + assert result.acked == 1 + assert result.submitted == 1 + assert result.failures == (SyncFailure.CACHE_REJECTED,) + + @pytest.mark.skipif(os.name == "nt", reason="POSIX advisory locks are required") def test_outbox_lock_contention_returns_a_closed_fail_open_sync_result(tmp_path: Path) -> None: import fcntl @@ -316,7 +356,7 @@ def release_later() -> None: CommonsConfig(CommonsMode.CONTRIBUTOR), cache=cache, outbox=outbox, - client=_RecordingClient(pack=_pack_bytes(), submit=CommonsAck(True, False)), + client=_RecordingClient(pack=_download(), submit=CommonsAck(True, False)), ) elapsed = time.monotonic() - started release.join(timeout=1) diff --git a/tests/commons_signing.py b/tests/commons_signing.py new file mode 100644 index 0000000..007fc1f --- /dev/null +++ b/tests/commons_signing.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import base64 +import hashlib +import json + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from marginal.commons.client import CommonsPackDownload + +ROOT_PRIVATE = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) +RELEASE_PRIVATE = Ed25519PrivateKey.from_private_bytes(bytes(range(32, 64))) +MODEL_NAMESPACES = ( + "openai/gpt-5.6-luna", + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", +) + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def public_bytes(private: Ed25519PrivateKey) -> bytes: + return private.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + +def canonical(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("utf-8") + + +def root_document() -> bytes: + return canonical( + { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "test-root", + "public_key": b64url(public_bytes(ROOT_PRIVATE)), + } + ) + + +def pack_bytes( + *, + revision: int = 1, + source_commit: str = "a" * 40, + models: dict[str, list[dict[str, object]]] | None = None, + compatibility: str = "1.0", + extra: tuple[str, object] | None = None, +) -> bytes: + payload: dict[str, object] = { + "schema_version": "1.0", + "source_commit": source_commit, + "commons_revision": revision, + "compatibility": {"evidence_envelope_schema_version": compatibility}, + "models": { + namespace: {"aggregates": (models or {}).get(namespace, [])} + for namespace in MODEL_NAMESPACES + }, + } + if extra is not None: + payload[extra[0]] = extra[1] + payload["integrity"] = {"sha256": hashlib.sha256(canonical(payload)).hexdigest()} + return canonical(payload) + + +def signed_download( + pack: bytes, + *, + not_before_revision: int = 1, + not_after_revision: int = 2_147_483_647, + release_private: Ed25519PrivateKey = RELEASE_PRIVATE, +) -> CommonsPackDownload: + certificate = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "test-release", + "public_key": b64url(public_bytes(release_private)), + "not_before_revision": not_before_revision, + "not_after_revision": not_after_revision, + } + envelope = { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "test-release", + "certificate": certificate, + "certificate_signature": { + "schema_version": "1.0", + "algorithm": "ed25519", + "key_id": "test-root", + "signature": b64url(ROOT_PRIVATE.sign(canonical(certificate))), + }, + "signature": b64url(release_private.sign(pack)), + } + return CommonsPackDownload(pack=pack, signature=canonical(envelope)) diff --git a/tests/integrations/codex/test_service.py b/tests/integrations/codex/test_service.py index c660b01..96bb834 100644 --- a/tests/integrations/codex/test_service.py +++ b/tests/integrations/codex/test_service.py @@ -8,6 +8,7 @@ import pytest import marginal.integrations.codex.service as service_module +from marginal.commons.client import CommonsPackDownload from marginal.commons.config import CommonsMode, configure_commons_mode from marginal.commons.outbox import CommonsOutbox from marginal.integrations.codex.events import SessionEvent @@ -237,7 +238,7 @@ def __init__(self) -> None: self.downloads = 0 self.submissions = 0 - def download(self) -> bytes: + def download(self) -> CommonsPackDownload: self.downloads += 1 raise TimeoutError("private network detail") diff --git a/tests/test_packaged_schemas_v2.py b/tests/test_packaged_schemas_v2.py index db47563..4b4baba 100644 --- a/tests/test_packaged_schemas_v2.py +++ b/tests/test_packaged_schemas_v2.py @@ -40,3 +40,19 @@ def test_schema_api_rejects_unknown_or_unsafe_names() -> None: pass else: raise AssertionError(f"expected schema lookup to reject {name!r}") + + +def test_runtime_packages_only_the_public_root_trust_anchor() -> None: + packaged = ROOT / "src" / "marginal" / "commons" + assert (packaged / "commons-root-key-v1.json").read_bytes() == ( + ROOT / "contracts" / "commons-root-key-v1.json" + ).read_bytes() + assert not (packaged / "commons-release-key-v1.json").exists() + assert not (packaged / "commons-release-key-v1.sig.json").exists() + + +def test_source_distribution_includes_trusted_release_tooling_inputs() -> None: + manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8").splitlines() + assert "include scripts/build_commons_release.py" in manifest + assert "include models/canonical-model-registry-v1.json" in manifest + assert "include requirements/commons-release.txt" in manifest