diff --git a/.github/workflows/verify-v2.yml b/.github/workflows/verify-v2.yml new file mode 100644 index 0000000..6dc2858 --- /dev/null +++ b/.github/workflows/verify-v2.yml @@ -0,0 +1,73 @@ +name: Verify crypto-agility v2 +on: + pull_request: + push: + branches: [main, review/automation-first-02] +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '24' + - name: Record exact tested commit + run: | + mkdir -p build/results + git rev-parse HEAD | tee build/results/commit.txt + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + - name: Install pinned validation dependencies + run: | + python -m pip install -r requirements.txt + npm ci --ignore-scripts + - name: Build pinned OpenSSL with hybrid TLS support + run: | + bash scripts/build-test-openssl.sh build/openssl > build/results/openssl-build.log 2>&1 + echo "OPENSSL_BINARY=$PWD/build/openssl/apps/openssl" >> "$GITHUB_ENV" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: quantakrypto/pqc-tools + ref: 6a81b5537eaedf61f259334b4e430e580c35942c + path: build/pqc-tools + persist-credentials: false + - name: Build pinned qScan integration dependency + working-directory: build/pqc-tools + run: | + npm ci --ignore-scripts + npm run build + - name: Unit, adversarial, real TLS, interoperability and performance tests + env: + REQUIRE_HYBRID_TLS: '1' + REQUIRE_QSCAN_INTEROP: '1' + QSCAN_ROOT: ${{ github.workspace }}/build/pqc-tools + run: | + set -o pipefail + python scripts/check-tests.py 2>&1 | tee build/results/tests.log + - name: Validate v2 example (expected findings) + run: | + python tools/verifier.py examples/crypto-agility-v2.json --now 2026-10-12T00:00:00Z > build/results/assessment.json || test "$?" -eq 1 + python -c 'import json; d=json.load(open("build/results/assessment.json")); assert d["status"]=="needs-review"; assert any(x["code"]=="fallback-risk" for x in d["findings"])' + - name: Render IETF XML without network references + run: | + python -m tools.render_draft --check + xml2rfc --no-network --text draft/draft-acosta-crypto-agility-manifest-02.xml --out build/results/draft-acosta-crypto-agility-manifest-02.txt + cmp build/results/draft-acosta-crypto-agility-manifest-02.txt draft/draft-acosta-crypto-agility-manifest-02.txt + xml2rfc --no-network --html draft/draft-acosta-crypto-agility-manifest-02.xml --out build/results/draft-acosta-crypto-agility-manifest-02.html + node_modules/.bin/idnits --mode submission --offline --output json draft/draft-acosta-crypto-agility-manifest-02.xml > build/results/idnits.json + python -c 'import json; d=json.load(open("build/results/idnits.json")); assert d["result"]=="pass" and not any(d["nitsBySeverity"].values())' + - name: Retain exact-commit validation evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: validation-${{ github.event.pull_request.head.sha || github.sha }} + path: build/results/ + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a309367 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +build/ +node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f32963e..f8ccd66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,72 +1,34 @@ # Contributing -Thanks for taking the time to look at the crypto-agility manifest. This is an early -individual proposal, so the most valuable contributions right now are review and -argument, not just code. +This is an individual proposal, not an IETF standard. Preserve version 1 while +reviewing version 2. The authoritative candidate is +`draft/draft-acosta-crypto-agility-manifest-02.xml`; its embedded normative schema +must equal `schema/crypto-agility-v2.schema.json`. Update both in the same PR. +Generate the Markdown rendering with `python -m tools.render_draft`. -## Where to start +Keep the core to discovery and evidence references. Reuse existing cryptographic +protocols. Distinguish declarations, authenticated assertions and independently +verified runtime behavior. Cite primary sources and separate normative dependencies +from background. Do not use em dashes or claim validation without evidence. -- **The specification** lives in [`draft/`](draft/). The - `draft-acosta-crypto-agility-manifest-01.xml` file is the authoritative source - (xml2rfc v3, RFC 7991 vocabulary); the `.md` file is a readable rendering kept in - sync with it. If you change one, change the other. -- **The contract** lives in [`schema/crypto-agility.schema.json`](schema/crypto-agility.schema.json) - (JSON Schema, draft 2020-12). The schema, the draft's Field Definitions section, and - the example in [`examples/`](examples/) must always agree. +## Validation -## How to propose a change - -1. Open an issue first for anything substantive (a new field, a changed requirement, a - security consideration). It is cheaper to agree on a change in prose before editing - three files. -2. For an accepted change, open a pull request that updates **all** of: the XML draft, - the Markdown rendering, the JSON Schema, and the example (whichever apply), so they - never drift apart. -3. Keep the tone level and non-hype. This document competes on being correct and - honest about what it is (a claim, not a proof), not on marketing. - -## House rules - -- **No em dashes** anywhere: in prose, code, or XML. Use commas, colons, parentheses, - or a spaced hyphen instead. -- **Verify, do not invent.** Every external standard cited must be checked against its - primary source, with a correct title, number, and link. Do not add a citation you - have not read. -- **Cite normatively vs informatively with care.** RFC 2119 / 8174 / 8259 / 3339 / 8615 / - 9110 and CycloneDX CBOM are normative (the draft depends on them to be implementable). The - crypto-agility survey references (RFC 7696, NIST CSWP 39, NIST IR 8547, NSM-10, - ISO/IEC 23837, NIST SP 800-56C) are informative. - -## Validating your change locally - -The example is checked against the schema with no third-party dependencies. Any small -JSON-Schema (draft 2020-12) validator works; if you have `ajv` available you can run it -directly, otherwise a short structural check over the schema's `required`, `type`, -`const`, and `additionalProperties` keywords is sufficient. Confirm that -`examples/crypto-agility.json` still validates after any schema change. - -Check the draft XML is well-formed before opening a PR: +Install the pinned dependencies from `requirements.txt`, then run: ```sh -xmllint --noout draft/draft-acosta-crypto-agility-manifest-01.xml +python -m unittest discover -s tests -v +python -m tools.render_draft --check +xml2rfc --text draft/draft-acosta-crypto-agility-manifest-02.xml --out /tmp/crypto-agility-02.txt ``` -## Licensing and IETF Trust provisions - -The repository (schema, examples, tooling notes) is licensed under **Apache-2.0**; see -[LICENSE](LICENSE). - -The Internet-Draft in [`draft/`](draft/) is a different matter. It carries the IETF -Trust legal provisions indicated by its `ipr="trust200902"` attribute, **regardless of -the repository's Apache-2.0 license**. By contributing changes to the draft you agree -that those contributions may be submitted to the IETF under the IETF Trust's terms -(see the IETF "Note Well" and BCP 78/79). If you are not comfortable with that, -contribute to the schema, examples, and tooling instead, and leave the draft prose to -be edited separately. +Run the independent consumer and live TLS checks required by CI. Required tests +must fail when their dependency or capability is absent, not silently skip. +Submission also requires review of current IETF author guidance, author contact, +revision availability, idnits output and any active submission deadline. Do not +submit or declare readiness while acceptance requirements remain unproven. -## Before an IETF submission +## Licensing -The draft is kept in submission-ready shape: the author contact -(`leon@quantakrypto.com`) and the IANA change-controller (`IETF`) are set. Validate the -XML with `xmllint --noout` and, ideally, render it through `xml2rfc` before submitting a -new revision, so the datatracker upload does not bounce on a format error. +Code, schemas and examples use Apache-2.0. Draft contributions are subject to the +IETF Trust provisions indicated by `ipr="trust200902"` and BCP 78/79. Do not publish +secrets or private inventories as evidence. diff --git a/README.md b/README.md index ca5a674..0e7d6f6 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,59 @@ # Crypto-Agility Manifest -A discoverable, machine-readable JSON document that a website or repository publishes -at `/.well-known/crypto-agility.json` to declare its cryptographic posture, so an -agent, a CI bot, or an auditor can read it the way it already reads `security.txt`. - -> **Status: proposal, not a ratified standard.** This repository holds an individual -> Internet-Draft and a reference schema. It is not an IETF working-group product, it -> does not represent IETF consensus, and nothing here is a standard. It is published -> to be reviewed, implemented, and argued with. - -## What this is - -The crypto-agility manifest is a small, versioned JSON object served from a well-known -URL. It summarizes, at a glance: - -- **posture**: a readiness score, whether hybrid post-quantum key exchange is in use, - a count of quantum-vulnerable findings by severity, and how many are exposed to - harvest-now-decrypt-later; -- **cbomSummary**: a compact digest of, and a pointer to, the full CycloneDX - Cryptography Bill of Materials (CBOM) the summary was derived from; -- **attestation** (optional): a link to a machine-verifiable posture credential; -- **policy**: the migration deadlines the project declares it is measured against. - -A worked, schema-valid example is in [`examples/crypto-agility.json`](examples/crypto-agility.json), -and the field-by-field contract is in [`schema/crypto-agility.schema.json`](schema/crypto-agility.schema.json). - -## What this is not - -- **Not a proof.** A manifest is a *self-reported claim*. Serving one says what a - project asserts about its posture, not what is true of it. The optional - `attestation` link is what turns a claim into something a third party can verify. -- **Not a replacement for CBOM.** [CycloneDX CBOM](https://cyclonedx.org/capabilities/cbom/) - is the inventory format that answers "what cryptography is in here". The manifest is - its public-facing *discovery* counterpart: a small summary at a predictable URL that - answers "where is this project's posture, and what does it summarize to". The - manifest carries a pointer (`cbomSummary.serialNumber`) to the full CBOM, it does - not inline it. -- **Not a new cryptographic mechanism, attestation format, or scoring method.** It - reuses existing pieces and only defines the well-known URI, the media type, and the - JSON fields. - -## What it builds on - -Crypto-agility as a concept is already well-standardized. This proposal does not -re-invent it; it adds one missing piece (a discoverable public posture manifest) on -top of the following work: - -| Reference | What it gives us | -|---|---| -| [RFC 9116](https://www.rfc-editor.org/info/rfc9116) - A File Format to Aid in Security Vulnerability Disclosure (`security.txt`) | The **structural model** this manifest follows: a small file at a well-known location, discoverable from an origin alone. | -| [RFC 8615](https://www.rfc-editor.org/info/rfc8615) - Well-Known URIs | The `/.well-known/` mechanism and the IANA registry the `crypto-agility.json` suffix is proposed for. | -| [RFC 9110](https://www.rfc-editor.org/info/rfc9110) - HTTP Semantics (STD 97) | The `Content-Type` / media-type framework (`application/json`) used to serve the manifest. | -| [RFC 7696](https://www.rfc-editor.org/info/rfc7696) - Guidelines for Cryptographic Algorithm Agility (BCP 201) | The IETF guidance on algorithm agility this posture is measured against. | -| [NIST CSWP 39](https://doi.org/10.6028/NIST.CSWP.39) - Considerations for Achieving Cryptographic Agility: Strategies and Practices (Dec 2025) | The organizational and lifecycle framing of crypto-agility. | -| [CycloneDX CBOM](https://cyclonedx.org/capabilities/cbom/) (in CycloneDX 1.6 / 1.7, published as ECMA-424) | The **inventory format** the manifest summarizes and links to. | -| [NIST SP 800-56C Rev. 2](https://doi.org/10.6028/NIST.SP.800-56Cr2) - Key-Derivation Methods in Key-Establishment Schemes | Key-establishment context for hybrid key exchange. | -| [ISO/IEC 23837-1:2023 and 23837-2:2023](https://www.iso.org/standard/77097.html) - Security requirements and test methods for quantum key distribution | Standardized evaluation context for quantum-era key exchange. | -| [NSM-10](https://www.quantum.gov/security/) - National Security Memorandum 10 (May 2022) | The policy driver: transition to quantum-resistant cryptography, mitigating quantum risk to the maximum extent feasible by 2035. | -| [NIST IR 8547](https://csrc.nist.gov/pubs/ir/8547/ipd) - Transition to Post-Quantum Cryptography Standards (Initial Public Draft) | The default migration timeline (deprecate after 2030, disallow after 2035) the `policy` block cites. | - -## The problem it solves - -AI coding agents and CI systems increasingly make dependency and cryptography choices, -and auditors increasingly want a machine-checkable posture signal. Today there is no -standard place for any of them to learn a project's crypto stance, so every -integration is bespoke. The manifest gives them one predictable URL and one small, -stable schema to read, the same convention `robots.txt` and `security.txt` established -for their domains. - -## Producing a manifest - -The reference implementation lands in [qScan](https://github.com/quantakrypto/pqc-tools) -(pqc-tools): an emitter that produces a manifest from a scan, and a validator for a -local one, offline: +An individual Internet-Draft for discovering declared cryptographic capabilities at +`/.well-known/crypto-agility.json`. This is a proposal, not an IETF standard. +A manifest is a claim. Neither a digest, a signature nor a compatibility result +proves that a system is secure or quantum resistant. + +## Version 2 candidate + +The [-02 XML](draft/draft-acosta-crypto-agility-manifest-02.xml) is authoritative for +the v2 candidate. The [readable draft](draft/draft-acosta-crypto-agility-manifest-02.md), +[JSON Schema](schema/crypto-agility-v2.schema.json) and +[example](examples/crypto-agility-v2.json) accompany it. Version 1 remains unchanged +for existing qScan producers. Unknown versions require an explicit consumer upgrade. + +The core describes subjects, capabilities, device profiles, evidence and migration +status. It separates declarations, observations and independent verification. +Optional evidence collection and monitoring belong to implementations, not a new +cryptographic protocol. The draft does not authorize configuration changes. + +## Reference tools + +- `tools.verifier`: bounded schema and semantic assessment, with no implicit trust +- `tools.tls_probe`: real TLS 1.3 negotiation through OpenSSL, certificate and hostname + validation, negotiated group and peer signature reporting +- `tools.observatory_ingestion`: signed collector evidence, endpoint binding, + transcript digest, freshness and provenance validation +- `tools.attestation`: compact JWS/JWT verification with pinned issuer policy, + explicit audience and purpose, expiry and persistent replay rejection +- `tools.discovery`: bounded HTTPS discovery and conditional caching +- `tools.qscan`: deterministic offline adaptation of existing qScan v1 output +- `tools.monitor`: repeated assessment, regression alerts and protected local state + +Ed25519 in the reference JWS profile is classical. A TLS hybrid key exchange does +not establish post-quantum authentication. Device compatibility is a conservative +comparison of declarations, not a device certification or fleet measurement. + +## Validation ```sh -# Emit the manifest to the conventional path (regenerate this in CI): -qscan . --crypto-agility -o .well-known/crypto-agility.json - -# Equivalent explicit subcommand, with an attestation link, a hybrid-KEX -# assertion, and an operator policy: -qscan crypto-agility emit . \ - --attestation https://quantakrypto.com/attest/acme \ - --hybrid-kex \ - --policy ./crypto-policy.json \ - -o .well-known/crypto-agility.json - -# Validate a local manifest against the schema (exit 0 = valid): -qscan crypto-agility validate ./.well-known/crypto-agility.json -``` - -Emitting is additive: it runs a scan, derives the manifest, and always exits 0, so -publishing a posture document never fails CI. The emitter and validator are strictly -offline; they never fetch a remote manifest or the `attestation.url`. Fetching and -verifying a *remote* manifest, and the credential behind its attestation link, is a -consumer-side or website-side step. - -You do not need qScan to participate: any tool that writes a document conforming to -[`schema/crypto-agility.schema.json`](schema/crypto-agility.schema.json) and serves it -at `/.well-known/crypto-agility.json` is emitting a valid manifest. - -## Repository layout - +python -m pip install -r requirements.txt +npm ci --ignore-scripts +python -m unittest discover -s tests -v +python -m tools.verifier examples/crypto-agility-v2.json --now 2026-10-12T00:00:00Z +xml2rfc --no-network --text draft/draft-acosta-crypto-agility-manifest-02.xml ``` -draft/ the Internet-Draft (xml2rfc v3 source + a readable Markdown rendering) -schema/ JSON Schema (draft 2020-12) for the manifest, version 1 -examples/ a realistic, schema-valid example manifest -``` - -- [`draft/draft-acosta-crypto-agility-manifest-01.xml`](draft/draft-acosta-crypto-agility-manifest-01.xml) - - the authoritative xml2rfc v3 source. -- [`draft/draft-acosta-crypto-agility-manifest-01.md`](draft/draft-acosta-crypto-agility-manifest-01.md) - - a readable rendering for GitHub. - -## Status and how to contribute -This is an early individual proposal. Review, issues, and pull requests are welcome, -especially on the field definitions, the security and operational considerations, and -the IANA registration. See [CONTRIBUTING.md](CONTRIBUTING.md). +The example intentionally reports fallback risks and the verifier exits 1. +Hosted CI requires actual hybrid TLS and pinned qScan interoperability; missing +runtimes are failures there. It retains logs, the exact tested commit and rendered +draft artifacts. See [implementation status](docs/implementation-status.md) for +acceptance evidence and [IETF readiness](docs/ietf-readiness.md) for submission gates. -Note that the Internet-Draft itself carries IETF Trust legal provisions (`trust200902`) -regardless of the repository's Apache-2.0 license; contributing changes to the draft -means agreeing those changes may be contributed under the IETF Trust's terms. The -repository code, schema, and examples are licensed under [Apache-2.0](LICENSE). +No production deployment or Internet-Draft submission is implied by passing the +reference tests. In particular, legacy unsigned measurements cannot be relabeled +as authenticated evidence. -The draft lists `leon@quantakrypto.com` as the author contact. Correspondence about the -proposal is welcome there or via issues on this repository. +Code is Apache-2.0. Internet-Drafts carry IETF Trust provisions (`trust200902`). +Author contact: `leon@quantakrypto.com`. See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/change-feed-design.md b/docs/change-feed-design.md new file mode 100644 index 0000000..0ecc935 --- /dev/null +++ b/docs/change-feed-design.md @@ -0,0 +1,31 @@ +# Discovery and optional refresh hints + +The only interoperable discovery resource in this candidate is the current manifest +at `/.well-known/crypto-agility.json`. No custom feed format, hash chain, sequence +protocol, or push service is required. Those earlier proposals added state and trust +problems without demonstrating a benefit over conditional HTTP retrieval. + +`tools.discovery.DiscoveryClient` uses an explicit origin allowlist, verified HTTPS, +public-address checking followed by a pinned-IP connection with hostname/SNI +validation, no proxies and no redirects. A worker deadline bounds DNS, TLS, response +headers and body reads together. Responses are limited to 1 MiB, uncompressed JSON, +16 levels of nesting and schema limits. Duplicate members and non-JSON numbers fail. + +The memory-only cache supports ETag/If-None-Match and Last-Modified/If-Modified-Since, +Cache-Control max-age, no-cache, no-store and Age/Date. Local max-age defaults to +300 seconds and cannot exceed 3600 seconds. There is at most one cached manifest +per configured origin, at most 256 origins. An expired or mismatched manifest is +rejected, including after 304. HTTP freshness never refreshes evidence timestamps: +the monitoring caller must reassess evidence and signatures on every evaluation. +The cache returns original bytes for exact snapshot digest binding. + +An operator can arrange a local authenticated notification to call +`fetch(configured_origin, expected_subject, force=True)`. The notification is only a +refresh hint. Its contents never supply a URL, security finding, or instruction. +Ordinary periodic polling recovers from lost, duplicated, reordered, or forged +notifications. A separate feed protocol should only be considered after an actual +interoperability use case demonstrates that HTTP validators are insufficient. + +Use `python -m unittest discover -s tests -p 'test_discovery.py' -v` for cache, +expiry, SSRF, redirect, malformed response, exact hostname validation and total +worker deadline tests. These tests do not claim production network availability. diff --git a/docs/cryptographic-security-review-iteration-2.md b/docs/cryptographic-security-review-iteration-2.md new file mode 100644 index 0000000..e54f2f9 --- /dev/null +++ b/docs/cryptographic-security-review-iteration-2.md @@ -0,0 +1,26 @@ +# Security review + +Threats considered: forged or replayed collector statements, wrong-subject evidence, +stale measurements in fresh wrappers, algorithm confusion, offered-group spoofing, +invalid certificates, malicious HTTP destinations, resource exhaustion, local state +symlinks and concurrent writers. The reference tests exercise these boundaries. + +The implementation uses standard TLS certificate/hostname verification and compact +JWS/JWT. It does not create a signature canonicalization algorithm, hybrid combiner, +Merkle construction or new certificate-validation mechanism. Ed25519 statement +signatures are classical; hybrid TLS key establishment says nothing by itself about +post-quantum authentication. + +Declared capabilities remain declarations. Authenticated observations mean an +explicitly trusted collector signed scoped evidence. Independently verified security +requires corroborating measurements and an explicit relying-party decision; a +publisher cannot select this status in a manifest. + +State is protected from partial writes, concurrent writers, permissive file modes, +symlinks and observable rollback. It cannot defend against the account owner, root, +loss of the state directory or restored disk images. An operator must retain and +recover trusted checkpoints. No alert delivery is claimed without a delivery receipt. + +Scope decisions and remaining deployment blockers are recorded in +[implementation-status.md](implementation-status.md). Source-level security review +and passing adversarial tests are not a cryptographic certification or an IETF review. diff --git a/docs/ietf-readiness.md b/docs/ietf-readiness.md new file mode 100644 index 0000000..0c1d893 --- /dev/null +++ b/docs/ietf-readiness.md @@ -0,0 +1,43 @@ +# IETF -02 readiness + +**Not submitted. The full requested acceptance criteria are not yet satisfied.** +The current branch prepares an individual proposal, not an IETF standard. + +## Prepared materials + +The authoritative RFCXML v3, generated Markdown, rendered text, embedded JSON +Schema and examples describe one v2 contract. Version 1 remains available for +existing qScan producers. The proposal requests a provisional well-known suffix; +it introduces no media type, signature format, feed protocol or cryptographic +algorithm. Contributor instructions require complete schema validation and +regeneration checks rather than a partial hand-written schema check. + +## Verified submission process (2026-10-11) + +- [Datatracker](https://datatracker.ietf.org/doc/draft-acosta-crypto-agility-manifest/) + lists -01; -02 is the next revision. The author email is `leon@quantakrypto.com`. +- [IETF author guidance](https://github.com/ietf/authors.ietf.org/blob/main/submitting-your-internet-draft.md) + prefers standalone RFCXML v3, validates with xml2rfc/idnits, and requires author + confirmation unless submitted by an authenticated listed author. +- [IETF 127 dates](https://datatracker.ietf.org/meeting/127/important-dates/) + list the draft cutoff as 2026-11-02 at 23:59 UTC. Recheck when submitting. +- Author confirmation and a submission transaction have not been attempted. + +## Remaining gates + +1. Hosted checks must pass on the exact commit retained in PR #2. +2. Complete the production issuer/exporter trust and deployment boundaries in + [implementation-status.md](implementation-status.md). Reference fixture keys + are not production trust anchors, and unsigned historical telemetry is not + authenticated evidence. +3. Demonstrate the required heterogeneous runtime interoperability. Current + profile tests assess declarations; Node/Python tests establish JWS and schema + interoperability, not independent complete security consumers. +4. Review the final idnits diagnostics and obtain independent IETF-area feedback + on the breaking v2 contract and registration. AI security review is not IETF + endorsement. Recheck author metadata, references and the submission deadline. + +No merge or submission is justified merely by a green reference test suite while +these acceptance gates remain open. + +The official Datatracker document and submission instructions were checked again during the 2026-10-11 follow-up: -01 remains published and -02 remains the intended next revision. Standalone RFCXML v3 and an idnits review remain the preparation path; author confirmation and a submission transaction were not attempted. Production follow-up evidence and unresolved key-status, ledger and delivery gates are recorded in implementation-status.md. No independent complete semantic consumer implementation or IETF-area endorsement is inferred from the Node/Python wire tests. diff --git a/docs/implementation-status.md b/docs/implementation-status.md new file mode 100644 index 0000000..5a95e0b --- /dev/null +++ b/docs/implementation-status.md @@ -0,0 +1,53 @@ +# Implementation and acceptance + +This branch implements reference tooling. It does not certify a deployment. +The workflow tests the PR head commit explicitly, not only GitHub's merge ref, +and retains logs and draft renderings as `validation-` artifacts. + +| Requirement | Reference implementation and evidence | Remaining acceptance boundary | +| --- | --- | --- | +| Authenticated ingestion | JWS issuer policy, endpoint/subject binding, transcript digest, provenance and freshness; ingestion and live pipeline tests | Companion Observatory collector/exporter and existing authority bridge are under test; deployment, production identity and fresh signed output remain unverified | +| TLS hybrid and fallback | Mandatory local OpenSSL 3.5.9 handshakes; hybrid, classical, deny-classical, invalid CA, wrong name, expired certificate, TLS 1.2 rejection | Tests cover the pinned TLS stack and fixture endpoints, not every provider, client or CDN edge | +| Signed attestations | Standard compact JWS/JWT, explicit algorithm policy, trusted issuer/key allowlists, revocation, audience, expiry, durable replay state; independent Node/OpenSSL/Python wire tests | RFC 9964 ML-DSA-65 support reuses the existing authority key; Existing-key KMS canary verified in website Actions; signed production deployment remains unverified | +| Compatibility | Conservative profile comparison across supported device classes, per-hop scope and constraint handling | Declared compatibility is not execution on browsers, mobile hardware, gateways or constrained devices; no fleet enrollment/coverage claim | +| Monitoring | Repeated assessments, stable actionable alert IDs, expiry/regression detection, atomic owner-only state, concurrent-writer and symlink tests | Operator must deploy supervisor and route alerts; state deletion/rollback by its owner requires operational recovery | +| Discovery | HTTPS certificate validation, origin allowlist, pinned public IP, total deadline/size limits, ETag/Last-Modified, no stale-on-error | Optional change notifications only trigger a snapshot refresh; no unnecessary feed protocol | +| qScan | Pinned real qScan v1 producer/scan, repeatable bytes and v2 adapter with exact evidence digest and repository/commit binding | Native qScan remains v1; this PR supplies the tested adapter rather than changing its release | +| Tests | Unit, adversarial, real TLS, Node/Python interoperability, qScan integration, concurrency and bounded performance gates | Structural schema and JWS interoperability do not establish independent full semantic implementations or device interoperability | +| Draft | Self-contained -02 XML, Markdown, schema, examples and offline text/HTML rendering | Internet-Draft submission checks and independent IETF-area review remain a separate gate | + +## Scope and necessity + +Keep discovery small. No custom signature envelope, bespoke Merkle construction, +new hybrid combiner, public feed hash chain, remediation executor or universal +readiness score is required. A fresh HTTP representation does not refresh evidence. +A signed observation authenticates its collector; independent verification must +be reported separately. The manifest never supplies its own trust policy. + +## Reproduce + +Install `requirements.txt` and `npm ci --ignore-scripts`. Build OpenSSL with +`bash scripts/build-test-openssl.sh build/openssl`. Check out pqc-tools at +`6a81b5537eaedf61f259334b4e430e580c35942c`, run `npm ci --ignore-scripts` and +`npm run build` there, then: + +```sh +OPENSSL_BINARY="$PWD/build/openssl/apps/openssl" \ +REQUIRE_HYBRID_TLS=1 REQUIRE_QSCAN_INTEROP=1 \ +QSCAN_ROOT="$PWD/build/pqc-tools" \ +python scripts/check-tests.py +``` + +Hosted CI must pass on the exact published commit. Local passing tests are not a +substitute. Merge and IETF submission remain blocked while the requested production +and interoperability acceptance boundaries above are unresolved. + +## Production validation follow-up (2026-10-11) + +The supplied PR heads and their hosted checks were verified before changes. Independent engineering and security review found monitor alert-loss and baseline-loss failure paths; the monitor now retains pending delivery IDs until explicit acknowledgment and preserves historical authenticated measurements across rejected evidence, without presenting them as current assurance. + +An isolated candidate collector on the existing host passed its typecheck and real TLS tests. At 18:03:23 UTC, it observed certificate-validated TLS 1.3 with X25519MLKEM768 for `https://quantakrypto.com`. The separately fetched exact manifest bytes hashed to `sha256:e36400bf76ac5c48791e85fa629fc0a220d032359ed06a8117480346eea0e443`. That record is unsigned, is not a completed manifest-bound issuance, and expires for export after one hour. The existing scheduler was not switched to the candidate. + +The deployed website still has legacy issuer jobs that fetch and overwrite its published ledger. Their history is not yet a single serialized durable observation ledger. The documented key-revocation endpoint returned HTTP 404; matching historical/live public keys and a valid root signature do not establish current non-revocation. No production consumer trust policy, persistent supervisor/replay/outbox deployment or confirmed alert receipt has been accepted. These gates block merge and full deployment even when fixture and hosted tests pass. Device compatibility remains unknown or declared wherever representative execution evidence is absent. + +The existing-key KMS canary succeeded in [website Actions run 38162473186](https://github.com/quantakrypto/website/actions/runs/38162473186) at website commit `36f64b7705d3f152f3780093f96a8fe4da7f0e37`. It matched the historical online key SPKI fingerprint, requested a bounded RAW signature and verified it locally. Legacy issuance was skipped; no token or ledger entry was created. This verifies existing-key signing access and continuity, not non-revocation, authorized observation issuance or a deployed consumer. diff --git a/docs/observatory-integration.md b/docs/observatory-integration.md new file mode 100644 index 0000000..fb33333 --- /dev/null +++ b/docs/observatory-integration.md @@ -0,0 +1,81 @@ +# Observatory evidence integration + +`tools.tls_probe.probe_openssl` collects a fresh TLS 1.3 handshake using the +operator's explicit endpoint and pinned IP address. OpenSSL performs certificate +chain and DNS/IP identity validation with `-verify_return_error`. The collector +bounds diagnostic output to 64 KiB, kills timed out subprocesses, sends no +application request and discards application stdout. A missing hybrid provider +fails rather than silently retrying with a classical configuration. + +Default offered groups are `X25519MLKEM768:X25519:secp256r1`. The reported group +must actually be negotiated. Classical fallback is reported separately; offer +only `X25519MLKEM768` to test a policy that denies classical negotiation. The +certificate/handshake signature is reported separately and never promoted to +PQC authentication. Every result covers one address, time and configuration. + +## Authenticated ingestion + +The collector returns `evidence` and `transcript`. A separately trusted issuer +places the unmodified evidence object in the `observation` claim of the compact +JWS/JWT profile in [signed-observations.md](signed-observations.md). Evidence +contains: + +- `profile: openssl-tls13-v1`, `observedAt` in UTC with whole-second precision; +- endpoint `host`, `port`, and the actual pinned IP `address`; +- probe `name`, OpenSSL `version`, offered `groups`, + `certificateValidation: pkix-hostname`, and process `exitCode`; +- `transcriptSha256`, a SHA-256 digest of the exact diagnostic bytes. + +The producer must collect new validated observations, sign under its deployed +issuer identity and transfer the token plus exact transcript through its chosen +transport. This repository does not distribute or discover private signing keys. +The consumer calls: + +```python +result = ingest_measurement( + token, transcript_bytes, trust_config, "https://example.org", + expected_audience="monitor", replay_store=ReplayStore("replay.json"), +) +``` + +The consumer requires an explicitly trusted issuer, key and subject/audience/ +purpose policy, valid signature, expiry, replay protection, fresh measurement, +exact endpoint/subject binding, transcript digest, successful validation and a +negotiated group from the offered list. A cryptographically valid token is +consumed even when its measurement is subsequently rejected; a correction needs +a new token identifier. `validUntil` is the earliest of token expiry, issuer +maximum age and measurement maximum age; cached reports must recheck it. A signed observation is an authenticated collector +statement, **not independent verification** of that collector's behavior. + +## Existing Observatory boundary + +The inspected Observatory baseline did not enforce a validated TLS 1.3 handshake +or retain all required evidence. Its historical database rows and public aggregates +cannot satisfy this authenticated profile. No unsigned legacy row is upgraded to +trusted evidence. qScan support hints also do not substitute for a validated +completed handshake. + +Companion Observatory changes collect new bounded, certificate-validated evidence +and support export through the existing authority. The authority issues standard +RFC 9964 ML-DSA-65 JWS using the existing online key, with explicitly configured +issuer, audience, subject and optional exact snapshot bindings. Local cross-repository +tests exercise actual Observatory collection, authority issuance, ingestion and +monitor regression handling. Those tests use isolated test keys and local endpoints. + +The running service's version, runtime identity, deployed issuer policy and fresh +production output must still be verified. A configured GitHub KMS identity does not +establish that the VM has that identity. Existing production keys are reused; no +private signing-service source or credentials are included in this repository. + +## Reproduce validation + +```sh +OPENSSL_BINARY=/path/to/openssl-3.5/bin/openssl REQUIRE_HYBRID_TLS=1 \ + python -m unittest discover -s tests -p 'test_tls_live.py' -v +python -m unittest discover -s tests -p 'test_observatory_ingestion.py' -v +``` + +The live test starts local TLS servers and exercises actual hybrid negotiation, +classical fallback, a hybrid-only refusal, hostname mismatch, untrusted +certificates and TLS 1.2 rejection. The required hybrid gate fails if the runtime +cannot negotiate the group. Synthetic transcript tests are not a substitute. diff --git a/docs/qscan-integration.md b/docs/qscan-integration.md new file mode 100644 index 0000000..8965aa9 --- /dev/null +++ b/docs/qscan-integration.md @@ -0,0 +1,48 @@ +# qScan v1 to v2 bridge + +The existing qScan command produces v1. This repository provides an offline bridge; +it does not silently change pqc-tools or claim that its CLI emits v2. The integration +is tested against pqc-tools commit +`6a81b5537eaedf61f259334b4e430e580c35942c` (core 0.12.0). + +Generate a source manifest with qScan, setting `GITHUB_REPOSITORY` and `GITHUB_SHA` +to the scanned repository and commit. Keep its exact bytes in `qscan-v1.json`. +Supply operator-declared profiles as a JSON array and run: + +```sh +python -m tools.qscan qscan-v1.json \ + --repository quantakrypto/example --commit COMMIT \ + --profiles profiles.json --expires-at 2026-10-13T00:00:00Z \ + > crypto-agility-v2.json +``` + +Repository and commit must match the source. The bridge copies source generation +time instead of relabeling an old scan as fresh, uses an explicit expiry, checks +both schemas, and emits deterministic bytes for fixed inputs. SHA-256 binds the +complete original input bytes, including tool version, counts, policy and optional +attestation URL. Store the original beside the v2 manifest for local verification. +The digest supplies integrity when compared against an independently trusted +reference; it is not a signature or scanner authenticity claim. + +Algorithm findings become `inferred` source-use observations. The operator's hybrid +flag remains `declared`, with null mapped to unknown. Absence of a finding never +becomes evidence that a deployment is safe. Profiles are supplied explicitly: +source scanning cannot identify deployed browsers, devices or TLS authentication. +Readiness scores, counts and old policy timelines remain available in the original +evidence, without promoting them to a universal security metric or fresh policy. +Legacy attestation URLs are neither followed nor promoted to trusted credentials. + +For the integration gate, check out the pinned pqc-tools revision, install/build its +packages, then run the following from this repository: + +```sh +QSCAN_ROOT=/path/to/pqc-tools REQUIRE_QSCAN_INTEROP=1 \ + python -m unittest discover -s tests -p 'test_qscan.py' -v +``` + +The gate asserts the producer checkout commit, runs the actual source scanner on an +RSA fixture, calls the actual qScan manifest producer twice with fixed generation +inputs, checks byte reproducibility, and validates both conversions. Built JavaScript +is preferred; Node 24 can also execute the unchanged TypeScript sources without +network dependencies. The committed fixture is generated by this same producer. +This is producer/consumer interoperability, not an independent cryptographic audit. diff --git a/docs/runtime-evidence.md b/docs/runtime-evidence.md new file mode 100644 index 0000000..f07969c --- /dev/null +++ b/docs/runtime-evidence.md @@ -0,0 +1,17 @@ +# Runtime evidence + +A declared profile says what a publisher claims. A transcript records an observation; +its bytes alone do not authenticate its source. The active OpenSSL collector checks +TLS 1.3 negotiation, the selected NamedGroup, PKIX certificate validation and hostname +validation. Classical fallback is observed separately. A hybrid exchange does not +make a classical certificate or peer signature post-quantum secure. + +`tools.observatory_ingestion` accepts a bounded transcript only when its compact JWS +validates against an operator-provisioned issuer policy and binds the exact endpoint, +probe parameters, observation time and transcript digest. The monitor additionally +requires the signed manifest digest. See `signed-observations.md` for claims, +replay, expiry and trust configuration. An authenticated collector statement remains +`independentlyVerified: false`; a relying party needs its own fresh probe to establish +an independent observation. + +No supported method proves every fleet member's behavior or inventory completeness. diff --git a/docs/signed-observations.md b/docs/signed-observations.md new file mode 100644 index 0000000..7e07d11 --- /dev/null +++ b/docs/signed-observations.md @@ -0,0 +1,133 @@ +# Signed observation profile + +The reference implementation uses compact JWS (RFC 7515), JWT claims (RFC 7519), +Ed25519 JOSE signing (RFC 8037), ML-DSA-65 (RFC 9964), and PyJWT with +RFC 8725 validation safeguards. +It signs the exact encoded JWS bytes, not a custom canonical JSON representation. +Ed25519 is classical; ML-DSA-65 is post-quantum. Both authenticate the issuer's statement, +not the truth of a measurement, PQC authentication, or fleet coverage. + +This is a local reference application profile, not a registered media type or a +required Internet-Draft wire format. Other attestation systems need their own +explicitly selected, independently reviewed verifier. Unsupported formats fail +closed; existing unsigned telemetry cannot become trusted merely by importing it. + +## Contract + +The protected header is exactly `alg` (`EdDSA` or `ML-DSA-65`), `kid`, and +`typ: pqc-observation+jwt`. Required claims are `iss`, `sub`, `aud` (one string), +`iat`, `exp` (integer NumericDate), `jti`, `purpose: pqc-observation`, and an +`observation` object. Optional `nbf` is checked. The verifier rejects duplicate +JSON members, unsupported headers, embedded key sources, future issuance, +expired or excessively old statements, and signatures from unconfigured keys. + +`verify_envelope` requires a local trust configuration, expected subject, expected +audience and persistent `ReplayStore`. `expected_claims` can require additional +signed scope/policy bindings; `expected_snapshot` binds `snapshotDigest` to the +caller's independently calculated value. The monitor requires +`sha256:` over the exact received manifest bytes. No canonicalization +is implied. The measurement signs the transcript digest and endpoint/probe metadata; +see `observatory-integration.md`. + +An operator provisions a configuration of this shape (the public-key placeholder +must be replaced by an independently authenticated, unpadded base64url 32-byte key): + +```json +{ + "issuers": { + "https://collector.example": { + "keys": {"collector-2026": "REPLACE_WITH_TRUSTED_ED25519_PUBLIC_KEY"}, + "subjects": ["https://service.example"], + "audiences": ["local-monitor"], + "purposes": ["pqc-observation"], + "maxAgeSeconds": 3600, + "maxLifetimeSeconds": 3600, + "revokedKeys": [] + } + } +} +``` + +Subject/audience/purpose matching is exact, with no wildcard or substring matching. +Remove an issuer/key, set issuer `disabled: true`, or add its key ID to +`revokedKeys` to revoke trust. The verifier never obtains keys from token URLs. +The local policy is operator-controlled, not an authenticated online revocation +service. Issuer age and lifetime limits default to 3600 seconds and cannot exceed +604800 seconds. No future-issuance clock tolerance is granted. + +## Existing ML-DSA authority + +For an existing AWS KMS `ML_DSA_65` key, pin the independently authenticated public +key returned by `GetPublicKey`. Each `keys[kid]` value uses this explicit shape: + +```json +{"alg":"ML-DSA-65","format":"spki-der","publicKey":"BASE64URL_UNPADDED_TRUSTED_DER"} +``` + +The DER SubjectPublicKeyInfo must match RFC 9881: OID `2.16.840.1.101.3.4.3.18`, +absent parameters, and a 1952-byte public key. Signature length is 3309 bytes. +An EdDSA string key never authorizes ML-DSA, and an explicit algorithm mismatch +fails before signature verification. Explicit EdDSA keys may use the same object +shape with `alg: EdDSA` and `format: raw`. + +[RFC 9964 Section 5](https://www.rfc-editor.org/rfc/rfc9964.html#section-5) requires +pure ML-DSA with an empty context; HashML-DSA and nonempty contexts are rejected. +KMS signs the exact ASCII JWS signing input with `SigningAlgorithm: +ML_DSA_SHAKE_256`, `MessageType: RAW`. Its input limit is 4096 bytes: a producer +using RAW must reject larger signing inputs, not silently prehash them. AWS also +defines compatible external-mu signing, but the RAW producer does not need it. +The signing input includes the protected header and payload, not the signature. + +Python cryptography 46 has no ML-DSA API. A PyJWT algorithm adapter delegates to +OpenSSL 3.5's maintained default provider via `pkeyutl -verify -rawin`, with a +5-second deadline and 4096-byte diagnostic limit. Set `OPENSSL_BINARY` locally to +the tested OpenSSL binary. Tokens cannot select binaries or providers. The +reference test build pins OpenSSL 3.5.9. Backend absence fails verification. + +Primary sources: [RFC 9964](https://www.rfc-editor.org/rfc/rfc9964.html), +[RFC 9881](https://www.rfc-editor.org/rfc/rfc9881.html), +[AWS KMS Sign](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html), +[AWS ML-DSA/JWT integration](https://aws.amazon.com/blogs/security/how-to-create-post-quantum-signatures-using-aws-kms-and-ml-dsa/), +and [OpenSSL ML-DSA](https://docs.openssl.org/3.5/man7/EVP_SIGNATURE-ML-DSA/). + +## Replay, storage and monitoring + +The replay store atomically consumes `(issuer, audience, jti)` until expiry and +pins the latest issuance time for each issuer/subject/audience/purpose. Older +issuance is rejected even with a new ID. There are at most 10000 active tokens and +10000 pinned scopes by default; scope pins do not expire. Capacity fails closed +rather than discarding anti-rollback history. Plan separate stores for bounded +fleets; deliberate rebootstrap requires a fresh trusted baseline. + +POSIX state files are owner-only, reject symbolic links/nonregular files, serialize +writers, and use unique atomic replacement with file and directory fsync. Keep +state in an owner-controlled directory. These controls cannot defend against the +account owner/root, deleted state, or a rolled-back filesystem backup. Protect +backups and do not delete replay state to recover a rejected token. + +The monitor's process-local cache reuses a previously authenticated result only +while exact token, transcript, manifest, audience, subject and trust-policy bytes +remain unchanged. Effective validity is the earliest of token expiry, issuer +maximum age, and measurement maximum age. Each check reevaluates validity. After a +restart the producer must provide a fresh token ID; replayed input is rejected. +A token whose signature passes but measurement semantics fail is consumed; a +correction requires a new ID. This prevents retry ambiguity from being treated as +new evidence. + +`python -m tools.monitor MANIFEST --state STATE --token TOKEN --transcript LOG +--trust TRUST --audience local-monitor --replay-state REPLAY --iterations 0` +repeats local checks. Set `--interval` (minimum 1 second), or use a supervisor for +one-shot checks. Replace `MANIFEST` with `--origin https://service.example +--subject https://service.example` for allowlisted HTTPS discovery with bounded +conditional caching. Discovery failure preserves the previous monitoring baseline. + +Alerts include stable IDs, severity and a concrete action; they cover new findings, removed coverage, TLS hybrid-to-classical changes and expired/lost authenticated evidence. With `--state`, pending alerts are saved atomically alongside the baseline and returned on subsequent checks until acknowledged. A failed output write or process restart therefore does not discard an undelivered alert. Each pending occurrence has a `deliveryId`; after confirmed destination delivery, run `python -m tools.monitor --state STATE --ack-alert DELIVERY_ID` (repeat `--ack-alert` for multiple receipts). A repeated acknowledgment is harmless and cannot acknowledge a later recurrence of the same stable alert ID. JSON output alone is not a delivery receipt: the supervisor must route alerts, confirm delivery, and acknowledge them. No production daemon or issuer deployment is implied. + +Rejected or missing measurements remain absent from the current report. The state separately retains the last authenticated measurements as historical regression baselines, so rejected evidence cannot hide a subsequent hybrid-to-classical change. These retained measurements never establish current assurance. Pending alerts and historical baselines share the bounded state file; exceeding its size limit fails closed and requires operator recovery rather than silently discarding history. + +## Interoperability boundary + +Tests use independent Node `crypto` JWS issuance and Python verification, including +real local TLS evidence. Production integration still requires a compatible issuer, +authorized collector keys, collector deployment and operational monitoring. No +private signing-service source or keys are included in this repository. diff --git a/docs/superpowers/plans/2026-10-11-production-validation.md b/docs/superpowers/plans/2026-10-11-production-validation.md new file mode 100644 index 0000000..ea363a8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-production-validation.md @@ -0,0 +1,19 @@ +# Crypto-agility v2 production validation plan + +**Goal:** Complete the existing PR implementation and verify production acceptance using the existing authority and collector. + +**Architecture:** Retain the deployed Observatory timer and database, existing AWS signing identity and online key. Keep authenticated transport, issuer authorization, exact snapshot binding and durable state explicit. Never substitute fixture tests for observed production behavior. + +**Constraints:** No private Authority source in public repositories, no credential copying, no new authority, one squashed change per affected repository, exact committed hosted CI, no IETF submission before readiness. + +- [x] Read instructions, implementations, workflows, deployment docs and PR discussions; isolate worktrees and confirm remote heads. +- [x] Discover production service, worker path, runtime and existing signing context without exposing environment values. +- [x] Fix monitor baseline retention and durable alert outbox with regression tests and independent security review. +- [x] Disclose the proposed TLS 1.3/certificate validation methodology break without inventing a rollout date or device measurements. +- [x] Verify existing-key canary in website Actions with public-key continuity pin and no ledger mutation. +- [ ] Validate authenticated collection, exact binding, one serialized persistent ledger, consumer trust and persistent monitor/replay state before deployment acceptance. +- [ ] Establish alert destination with the operator and verify receipt; retain pending alerts until explicit acknowledgement. +- [ ] Run exact-tree hosted checks, review resulting evidence and merge only if every acceptance boundary is met. +- [ ] Recheck IETF requirements and preserve a handoff for any remaining operational or independent-review blocker. + +Review focus: crash after state persistence; rejected evidence followed by a real TLS regression; legacy writers racing the issuer; unsigned transport records substituted for authenticated collection; unpublished or unverified method change misrepresented as observed rollout. diff --git a/draft/draft-acosta-crypto-agility-manifest-02.md b/draft/draft-acosta-crypto-agility-manifest-02.md new file mode 100644 index 0000000..11adf8b --- /dev/null +++ b/draft/draft-acosta-crypto-agility-manifest-02.md @@ -0,0 +1,516 @@ +# A Well-Known Crypto-Agility Manifest + +`draft-acosta-crypto-agility-manifest-02` | individual Informational proposal | not submitted + +Author: Leon Acosta, QuantaKrypto, leon@quantakrypto.com + + + +## Abstract + +This document defines a small JSON discovery manifest for cryptographic migration assessment. It distinguishes declarations, runtime observations and consumer verification, and separates key establishment from authentication. It covers heterogeneous endpoint profiles and evidence references. It does not define cryptographic negotiation, an attestation format, a scoring system, or authority to modify systems. + +## Introduction + +Cryptographic migration decisions need discoverable information about a specific system. A public manifest supplies declarations and evidence references; it cannot establish that every client, device or network path uses the declared algorithms. This individual proposal has no IETF consensus and does not claim universal post-quantum security. + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] + [RFC8174] when, and only when, they appear in all capitals, as shown here. + +## Discovery and Versioning + +A producer publishes the manifest at /.well-known/crypto-agility.json on an HTTPS origin, using application/json [RFC8615] + [RFC9110]. An origin is a scheme, host and port tuple [RFC6454]. This profile uses port 443 and DNS hostnames. Consumers MUST authenticate the HTTPS server and MUST NOT follow redirects automatically. Publication by an origin establishes only who served the declaration. + +The relying party MUST configure the expected subject independently. For subject.kind origin, subject.id MUST equal the configured HTTPS origin serialized under [RFC6454]. Other kinds require an independently configured mapping from the discovery origin to the exact subject.id. Consumers MUST NOT infer authority over sibling hosts, repositories, fleets or deployment environments from a shared publisher. + +Version 2 replaces, and is not an additive extension of, version 1. Both use the same discovery location. Producers serve one representation; consumers MUST reject unsupported versions rather than reinterpret their fields. A version 1 readiness score or hybridKex declaration MUST NOT be upgraded into a version 2 runtime observation. There is no downgrade or content-negotiation protocol in this document. + +Producers SHOULD supply ETag or Last-Modified validators and Cache-Control. Consumers SHOULD use conditional GET under [RFC9110] and [RFC9111], honor no-store and revalidation directives, and impose a local maximum cache age. A 304 response reuses bytes but MUST NOT extend manifest, evidence or observation validity. HTTP errors and unavailable manifests yield unknown posture, never unsupported cryptography. + +Optional external change feeds MAY trigger a bounded refresh of the configured discovery origin. This document defines no feed URL, event schema, signing scheme or hash chain. Notifications MUST NOT replace snapshot validation, authorize execution, or redirect evidence fetching. Polling with backoff remains a recovery mechanism. + +## Data Model + +The JSON object MUST conform to the schema in Appendix A, using JSON Schema 2020-12 [JSONSCHEMA] + [JSONVALIDATION]. Strings are case-sensitive. Unknown properties and duplicate JSON member names MUST be rejected. Producers MUST use UTF-8 [RFC8259]. Documents MUST NOT exceed 1048576 octets or 16 nested containers. Each array has at most 256 entries, except profiles, which has at most 64; strings have at most 2048 Unicode code points unless the schema sets a stricter bound. + +Timestamps use a restricted [RFC3339] form: uppercase T and Z, UTC, zero through six fractional-second digits, and no leap-second value. Consumers MUST validate calendar values and reject expiresAt less than or equal to generatedAt. They MUST NOT treat expired data as current. A local clock-skew allowance MAY be used; the reference consumer permits at most 300 seconds for future timestamps. + +Every id MUST be unique within its collection. References in evidenceIds and profileIds MUST resolve within this manifest. Missing references, contradictory supported and unsupported values for one capability, and future observations MUST produce findings rather than a passing security decision. + +The required version is the integer 2 and manifestType is crypto-agility. generatedAt identifies document generation and expiresAt its validity limit. Neither timestamp establishes when any endpoint was measured. subject contains required kind and id; optional environment and commit narrow descriptive context and do not prove deployed code. + +observations is a required array, possibly empty. Each entry contains id, capability, value (supported, unsupported or unknown), method (declared, inferred or observed), observedAt and optional evidenceIds. For declared and inferred methods, observedAt is when the assertion was recorded. Capability names are descriptive identifiers; this document defines no global capability registry. Producers SHOULD use a protocol-specific registered algorithm identifier or an unambiguous implementation name where available. + +evidence is an optional array. Each entry contains id, mediaType, digest and subjectId; uri is optional. subjectId MUST equal manifest subject.id. The digest object contains algorithm (sha-256, sha-384 or sha-512) and the exact lowercase hexadecimal value, respectively 64, 96 or 128 digits [RFC6234]. Hashing covers the complete evidence bytes before parsing or reserialization; HTTP content codings, if used, are removed first. Consumers MUST verify the digest before interpreting evidence. A digest match establishes byte integrity relative to the declaration, not authenticity. + +profiles is a nonempty array of declared endpoint capability classes. Each contains id, endpointRole (client, server, peer or gateway), deviceClass, protocol, keyEstablishment, authentication and fallback (deny-classical, allow-classical or unknown). Empty algorithm arrays mean unspecified inventory, not demonstrated absence of support. Optional constraints are human-readable limitations; evidenceIds link supporting evidence. The schema lists device classes including browsers, mobile devices, servers, IoT, embedded and industrial devices. + +migration is a required array, possibly empty. Each entry contains id, usage and status. Status values are unknown, planned, blocked, in-progress, validated and deployed. Optional algorithm, target, deadline, profileIds, evidenceIds and blockers describe the plan. A producer claiming validated or deployed MUST supply evidenceIds; the consumer MUST independently verify relevant validation or deployment evidence before relying on that claim. + +policies and exceptions are optional descriptive records, not remotely granted policy authority. Policy entries contain id, scope, source and optional effectiveAt. Exception entries contain findingId, reason, expiresAt and optional owner. Consumers MUST NOT use a manifest-supplied policy or exception to suppress a security finding without separate local authorization; expired or unowned exceptions are not valid enforcement exceptions. + +## Observations and Verification + +Declared means an operator assertion. Inferred means a tool derived a conclusion, for example from a source scan. Observed means a runtime measurement was performed and requires at least one evidence reference. This producer-supplied label is not evidence verification. Independently verified is a consumer result and is deliberately not a manifest method value. + +Before accepting observed evidence, consumers MUST verify subject, scope, acquisition time, freshness and provenance against locally configured policy. They MUST impose a maximum measurement age even when the manifest is still fresh. The reference consumer uses 86400 seconds by default. Stale, unavailable, incomplete or conflicting evidence cannot establish current protection. + +Consumers that accept signed evidence MUST use an existing signature or attestation format with a documented application profile. They MUST constrain algorithms, verify signatures with independently configured issuer keys, and validate issuer authorization, audience, purpose, subject and validity. Replay handling MUST bind accepted statement identifiers or freshness checkpoints to the issuer and subject in protected durable state. Loss of that state requires explicit recovery; a newly issued wrapper MUST NOT refresh an old measurement. [RFC8725] describes relevant JWT validation requirements. No signature format or mandatory signature algorithm is introduced here. + +A trusted signature authenticates what an issuer asserted, not the truth or completeness of that assertion. Independent verification requires a relying party to evaluate measurement evidence or repeat the measurement through a trusted process. A consumer MUST report which of signature authenticity, evidence integrity, measurement validity and independent corroboration it actually checked. + +For TLS 1.3 [RFC8446], a claim of hybrid key establishment MUST identify the negotiated group from a completed authenticated connection. An offered group, cipher-suite name or self-reported capability is insufficient. Certificate-chain validation, reference-identity matching and handshake authentication MUST succeed. The probe configuration, endpoint identity, time and group MUST be bound to the evidence. Authentication algorithms MUST be assessed separately: hybrid key establishment does not make an RSA or ECDSA certificate post-quantum secure. + +A classical-only negotiation MUST be reported as classical. Testing a classical-only peer distinguishes permitted fallback from a deny-classical policy; it does not prove resistance to every active downgrade attack. Implementations MUST rely on protocol-native authenticated negotiation and MUST NOT construct hybrid cryptography from manifest fields. + +The reference implementation accepts Ed25519 or ML-DSA-65 signatures under an explicit local key-algorithm policy. ML-DSA-65 JWS uses [RFC9964] with an empty context and pure ML-DSA, not HashML-DSA. Its public-key import follows [RFC9881]. An existing signing authority can issue this standard envelope without moving its private key. Successful signature verification does not verify the signed measurement. + +## Compatibility and Monitoring + +Compatibility from profile intersection is only declared compatibility. A consumer MUST NOT infer interoperability from deviceClass alone. Empty inventories, unresolved firmware or resource constraints and untested authentication trust yield unknown. A definite protocol mismatch or absence of common declared algorithms can be reported as a declared mismatch. Real interoperability requires measurements on each relevant software, firmware and trust-store combination. + +A gateway that terminates TLS creates separate hops. A successful browser-to-gateway test MUST NOT establish gateway-to-device protection. Offline or constrained devices SHOULD be represented through an authenticated fleet gateway with explicit connectivity constraints; missing telemetry is unknown. This document supplies no device enrollment protocol and no guarantee of inventory coverage. + +Monitors SHOULD detect expired evidence, changed algorithms, classical fallback, missing profiles and invalid snapshots. They SHOULD preserve the last valid baseline when new input fails, distinguish failure from recovery, and emit actionable, deduplicated local alerts. State MUST be scoped to the subject and protected against unauthorized modification and rollback to the extent provided by the local platform. A manifest does not authorize a monitor to change firmware, keys, software or network policy. + +## Security and Privacy Considerations + +All public content is untrusted, including signed content from a compromised issuer. Consumers MUST bound retrieval time, response bytes, parsing, evidence size and retries, and defend against server-side request forgery, DNS rebinding and unsafe redirects. Network destinations MUST be independently authorized. Public discovery MUST NOT automatically fetch private addresses, local file references or issuer-supplied keys. Locally provisioned evidence directories MAY be used by offline tools with path confinement. + +URLs, descriptive strings and constraints MUST NOT be executed or interpreted as instructions. Public manifests SHOULD omit secrets, internal hostnames, device identifiers and detailed vulnerability inventories. Producers should assess the privacy impact of exposing migration gaps and change cadence. Consumers MUST NOT equate scores, declarations, signatures or successful sample measurements with comprehensive security or regulatory compliance. + +## IANA Considerations + +This document proposes provisional registration of the following well-known URI under [RFC8615]. Registration is requested, not assumed to have been approved. + +URI suffix: crypto-agility.json. Change controller: Leon Acosta (leon@quantakrypto.com). Specification document: this document, Sections 2 and 3. Status: provisional. Related information: HTTPS on port 443, DNS hostnames, application/json. No additional path components, query parameters or fragment identifiers are defined. + +## JSON Schema + +This schema is normative. Cross-reference resolution, time ordering, uniqueness of record identifiers, subject binding and evidence verification also require the semantic checks specified above. The schema is bundled; validators MUST NOT fetch schemas selected by an untrusted manifest. + +```json +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "title":"Crypto-Agility Manifest version 2", + "type":"object", + "additionalProperties":false, + "required": + [ + "version", + "manifestType", + "generatedAt", + "expiresAt", + "subject", + "observations", + "migration", + "profiles" + ], + "properties": + { + "version":{"const":2}, + "manifestType":{"const":"crypto-agility"}, + "generatedAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "expiresAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "subject": + { + "type":"object", + "additionalProperties":false, + "required":["kind","id"], + "properties": + { + "kind": + { + "enum": + [ + "origin", + "service", + "repository", + "artifact", + "device-fleet" + ] + }, + "id":{"type":"string","minLength":1,"maxLength":2048}, + "environment": + {"type":"string","minLength":1,"maxLength":2048}, + "commit":{"type":"string","minLength":1,"maxLength":2048} + } + }, + "observations": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","capability","value","method","observedAt"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "capability": + {"type":"string","minLength":1,"maxLength":2048}, + "value":{"enum":["supported","unsupported","unknown"]}, + "method":{"enum":["observed","inferred","declared"]}, + "observedAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + } + } + }, + "maxItems":256 + }, + "evidence": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","mediaType","digest","subjectId"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "uri": + { + "type":"string", + "format":"uri", + "minLength":1, + "maxLength":2048 + }, + "mediaType": + {"type":"string","minLength":1,"maxLength":2048}, + "digest": + { + "type":"object", + "additionalProperties":false, + "required":["algorithm","value"], + "properties": + { + "algorithm":{"enum":["sha-256","sha-384","sha-512"]}, + "value": + { + "type":"string", + "pattern":"^[0-9a-f]+$", + "minLength":1, + "maxLength":2048 + } + }, + "allOf": + [ + { + "if": + {"properties":{"algorithm":{"const":"sha-256"}}}, + "then": + { + "properties": + {"value":{"minLength":64,"maxLength":64}} + } + }, + { + "if": + {"properties":{"algorithm":{"const":"sha-384"}}}, + "then": + { + "properties": + {"value":{"minLength":96,"maxLength":96}} + } + }, + { + "if": + {"properties":{"algorithm":{"const":"sha-512"}}}, + "then": + { + "properties": + {"value":{"minLength":128,"maxLength":128}} + } + } + ] + }, + "subjectId": + {"type":"string","minLength":1,"maxLength":2048} + } + }, + "maxItems":256 + }, + "migration": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","usage","status"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "usage": + { + "enum": + [ + "key-establishment", + "authentication", + "signature", + "encryption", + "other" + ] + }, + "algorithm": + {"type":"string","minLength":1,"maxLength":2048}, + "target":{"type":"string","minLength":1,"maxLength":2048}, + "status": + { + "enum": + [ + "unknown", + "planned", + "blocked", + "in-progress", + "validated", + "deployed" + ] + }, + "deadline": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "profileIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "blockers": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256 + } + } + }, + "maxItems":256 + }, + "profiles": + { + "type":"array", + "minItems":1, + "items": + { + "type":"object", + "additionalProperties":false, + "required": + [ + "id", + "endpointRole", + "deviceClass", + "protocol", + "keyEstablishment", + "authentication", + "fallback" + ], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "endpointRole": + {"enum":["client","server","peer","gateway"]}, + "deviceClass": + { + "enum": + [ + "browser", + "mobile", + "desktop", + "server", + "iot", + "embedded", + "industrial", + "network", + "other" + ] + }, + "protocol": + {"type":"string","minLength":1,"maxLength":2048}, + "keyEstablishment": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "authentication": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "fallback": + {"enum":["deny-classical","allow-classical","unknown"]}, + "constraints": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256 + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + } + } + }, + "maxItems":64 + }, + "policies": + { + "type":"array", + "items": + { + "type":"object", + "required":["id","scope","source"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "scope":{"type":"string","minLength":1,"maxLength":2048}, + "source":{"type":"string","minLength":1,"maxLength":2048}, + "effectiveAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties":false + }, + "maxItems":256 + }, + "exceptions": + { + "type":"array", + "items": + { + "type":"object", + "required":["findingId","reason","expiresAt"], + "properties": + { + "findingId": + {"type":"string","minLength":1,"maxLength":2048}, + "reason":{"type":"string","minLength":1,"maxLength":2048}, + "owner":{"type":"string","minLength":1,"maxLength":2048}, + "expiresAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties":false + }, + "maxItems":256 + } + }, + "$id": + "https://quantakrypto.com/schema/crypto-agility-v2.schema.json" +} +``` + +## Minimal Example + +This example has no runtime measurements. Its profile is a declaration, not demonstrated compatibility or security. + +```json +{ + "version": 2, + "manifestType": "crypto-agility", + "generatedAt": "2026-10-11T10:00:00Z", + "expiresAt": "2026-10-12T10:00:00Z", + "subject": { + "kind": "origin", + "id": "https://example.org" + }, + "observations": [], + "migration": [], + "profiles": [ + { + "id": "service", + "endpointRole": "server", + "deviceClass": "server", + "protocol": "TLS 1.3", + "keyEstablishment": [], + "authentication": [], + "fallback": "unknown" + } + ] +} +``` + +## Normative References + +- [RFC2119] [Key words for use in RFCs to Indicate Requirement Levels](https://www.rfc-editor.org/info/rfc2119) +- [RFC8174] [Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words](https://www.rfc-editor.org/info/rfc8174) +- [RFC8259] [The JavaScript Object Notation (JSON) Data Interchange Format](https://www.rfc-editor.org/info/rfc8259) +- [RFC3339] [Date and Time on the Internet: Timestamps](https://www.rfc-editor.org/info/rfc3339) +- [RFC8615] [Well-Known Uniform Resource Identifiers (URIs)](https://www.rfc-editor.org/info/rfc8615) +- [RFC9110] [HTTP Semantics](https://www.rfc-editor.org/info/rfc9110) +- [RFC9111] [HTTP Caching](https://www.rfc-editor.org/info/rfc9111) +- [RFC6454] [The Web Origin Concept](https://www.rfc-editor.org/info/rfc6454) +- [RFC6234] [US Secure Hash Algorithms (SHA and SHA-based HMAC and HKDF)](https://www.rfc-editor.org/info/rfc6234) +- [JSONSCHEMA] [JSON Schema: A Media Type for Describing JSON Documents](https://json-schema.org/draft/2020-12/json-schema-core) +- [JSONVALIDATION] [JSON Schema Validation: A Vocabulary for Structural Validation of JSON](https://json-schema.org/draft/2020-12/json-schema-validation) + +## Informative References + +- [RFC8446] [The Transport Layer Security (TLS) Protocol Version 1.3](https://www.rfc-editor.org/info/rfc8446) +- [RFC8725] [JSON Web Token Best Current Practices](https://www.rfc-editor.org/info/rfc8725) +- [RFC9964] [ML-DSA for JSON Object Signing and Encryption (JOSE) and CBOR Object Signing and Encryption (COSE)](https://www.rfc-editor.org/info/rfc9964) +- [RFC9881] [Internet X.509 Public Key Infrastructure -- Algorithm Identifiers for the Module-Lattice-Based Digital Signature Algorithm (ML-DSA)](https://www.rfc-editor.org/info/rfc9881) diff --git a/draft/draft-acosta-crypto-agility-manifest-02.txt b/draft/draft-acosta-crypto-agility-manifest-02.txt new file mode 100644 index 0000000..d87c7d4 --- /dev/null +++ b/draft/draft-acosta-crypto-agility-manifest-02.txt @@ -0,0 +1,952 @@ + + + + +Network Working Group L. Acosta +Internet-Draft QuantaKrypto +Intended status: Informational 11 October 2026 +Expires: 14 April 2027 + + + A Well-Known Crypto-Agility Manifest + draft-acosta-crypto-agility-manifest-02 + +Abstract + + This document defines a small JSON discovery manifest for + cryptographic migration assessment. It distinguishes declarations, + runtime observations and consumer verification, and separates key + establishment from authentication. It covers heterogeneous endpoint + profiles and evidence references. It does not define cryptographic + negotiation, an attestation format, a scoring system, or authority to + modify systems. + +Status of This Memo + + This Internet-Draft is submitted in full conformance with the + provisions of BCP 78 and BCP 79. + + Internet-Drafts are working documents of the Internet Engineering + Task Force (IETF). Note that other groups may also distribute + working documents as Internet-Drafts. The list of current Internet- + Drafts is at https://datatracker.ietf.org/drafts/current/. + + Internet-Drafts are draft documents valid for a maximum of six months + and may be updated, replaced, or obsoleted by other documents at any + time. It is inappropriate to use Internet-Drafts as reference + material or to cite them other than as "work in progress." + + This Internet-Draft will expire on 14 April 2027. + +Copyright Notice + + Copyright (c) 2026 IETF Trust and the persons identified as the + document authors. All rights reserved. + + + + + + + + + + + +Acosta Expires 14 April 2027 [Page 1] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents (https://trustee.ietf.org/ + license-info) in effect on the date of publication of this document. + Please review these documents carefully, as they describe your rights + and restrictions with respect to this document. Code Components + extracted from this document must include Revised BSD License text as + described in Section 4.e of the Trust Legal Provisions and are + provided without warranty as described in the Revised BSD License. + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . 2 + 2. Discovery and Versioning . . . . . . . . . . . . . . . . . . 2 + 3. Data Model . . . . . . . . . . . . . . . . . . . . . . . . . 3 + 4. Observations and Verification . . . . . . . . . . . . . . . . 5 + 5. Compatibility and Monitoring . . . . . . . . . . . . . . . . 6 + 6. Security and Privacy Considerations . . . . . . . . . . . . . 7 + 7. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 7 + 8. Normative References . . . . . . . . . . . . . . . . . . . . 7 + 9. Informative References . . . . . . . . . . . . . . . . . . . 8 + Appendix A. JSON Schema . . . . . . . . . . . . . . . . . . . . 9 + Appendix B. Minimal Example . . . . . . . . . . . . . . . . . . 17 + Author's Address . . . . . . . . . . . . . . . . . . . . . . . . 17 + +1. Introduction + + Cryptographic migration decisions need discoverable information about + a specific system. A public manifest supplies declarations and + evidence references; it cannot establish that every client, device or + network path uses the declared algorithms. This individual proposal + has no IETF consensus and does not claim universal post-quantum + security. + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and + "OPTIONAL" in this document are to be interpreted as described in BCP + 14 [RFC2119] [RFC8174] when, and only when, they appear in all + capitals, as shown here. + +2. Discovery and Versioning + + A producer publishes the manifest at /.well-known/crypto-agility.json + on an HTTPS origin, using application/json [RFC8615] [RFC9110]. An + origin is a scheme, host and port tuple [RFC6454]. This profile uses + port 443 and DNS hostnames. Consumers MUST authenticate the HTTPS + server and MUST NOT follow redirects automatically. Publication by + an origin establishes only who served the declaration. + + + + +Acosta Expires 14 April 2027 [Page 2] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + The relying party MUST configure the expected subject independently. + For subject.kind origin, subject.id MUST equal the configured HTTPS + origin serialized under [RFC6454]. Other kinds require an + independently configured mapping from the discovery origin to the + exact subject.id. Consumers MUST NOT infer authority over sibling + hosts, repositories, fleets or deployment environments from a shared + publisher. + + Version 2 replaces, and is not an additive extension of, version 1. + Both use the same discovery location. Producers serve one + representation; consumers MUST reject unsupported versions rather + than reinterpret their fields. A version 1 readiness score or + hybridKex declaration MUST NOT be upgraded into a version 2 runtime + observation. There is no downgrade or content-negotiation protocol + in this document. + + Producers SHOULD supply ETag or Last-Modified validators and Cache- + Control. Consumers SHOULD use conditional GET under [RFC9110] and + [RFC9111], honor no-store and revalidation directives, and impose a + local maximum cache age. A 304 response reuses bytes but MUST NOT + extend manifest, evidence or observation validity. HTTP errors and + unavailable manifests yield unknown posture, never unsupported + cryptography. + + Optional external change feeds MAY trigger a bounded refresh of the + configured discovery origin. This document defines no feed URL, + event schema, signing scheme or hash chain. Notifications MUST NOT + replace snapshot validation, authorize execution, or redirect + evidence fetching. Polling with backoff remains a recovery + mechanism. + +3. Data Model + + The JSON object MUST conform to the schema in Appendix A, using JSON + Schema 2020-12 [JSONSCHEMA] [JSONVALIDATION]. Strings are case- + sensitive. Unknown properties and duplicate JSON member names MUST + be rejected. Producers MUST use UTF-8 [RFC8259]. Documents MUST NOT + exceed 1048576 octets or 16 nested containers. Each array has at + most 256 entries, except profiles, which has at most 64; strings have + at most 2048 Unicode code points unless the schema sets a stricter + bound. + + Timestamps use a restricted [RFC3339] form: uppercase T and Z, UTC, + zero through six fractional-second digits, and no leap-second value. + Consumers MUST validate calendar values and reject expiresAt less + than or equal to generatedAt. They MUST NOT treat expired data as + current. A local clock-skew allowance MAY be used; the reference + consumer permits at most 300 seconds for future timestamps. + + + +Acosta Expires 14 April 2027 [Page 3] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + Every id MUST be unique within its collection. References in + evidenceIds and profileIds MUST resolve within this manifest. + Missing references, contradictory supported and unsupported values + for one capability, and future observations MUST produce findings + rather than a passing security decision. + + The required version is the integer 2 and manifestType is crypto- + agility. generatedAt identifies document generation and expiresAt its + validity limit. Neither timestamp establishes when any endpoint was + measured. subject contains required kind and id; optional environment + and commit narrow descriptive context and do not prove deployed code. + + observations is a required array, possibly empty. Each entry + contains id, capability, value (supported, unsupported or unknown), + method (declared, inferred or observed), observedAt and optional + evidenceIds. For declared and inferred methods, observedAt is when + the assertion was recorded. Capability names are descriptive + identifiers; this document defines no global capability registry. + Producers SHOULD use a protocol-specific registered algorithm + identifier or an unambiguous implementation name where available. + + evidence is an optional array. Each entry contains id, mediaType, + digest and subjectId; uri is optional. subjectId MUST equal manifest + subject.id. The digest object contains algorithm (sha-256, sha-384 + or sha-512) and the exact lowercase hexadecimal value, respectively + 64, 96 or 128 digits [RFC6234]. Hashing covers the complete evidence + bytes before parsing or reserialization; HTTP content codings, if + used, are removed first. Consumers MUST verify the digest before + interpreting evidence. A digest match establishes byte integrity + relative to the declaration, not authenticity. + + profiles is a nonempty array of declared endpoint capability classes. + Each contains id, endpointRole (client, server, peer or gateway), + deviceClass, protocol, keyEstablishment, authentication and fallback + (deny-classical, allow-classical or unknown). Empty algorithm arrays + mean unspecified inventory, not demonstrated absence of support. + Optional constraints are human-readable limitations; evidenceIds link + supporting evidence. The schema lists device classes including + browsers, mobile devices, servers, IoT, embedded and industrial + devices. + + migration is a required array, possibly empty. Each entry contains + id, usage and status. Status values are unknown, planned, blocked, + in-progress, validated and deployed. Optional algorithm, target, + deadline, profileIds, evidenceIds and blockers describe the plan. A + producer claiming validated or deployed MUST supply evidenceIds; the + consumer MUST independently verify relevant validation or deployment + evidence before relying on that claim. + + + +Acosta Expires 14 April 2027 [Page 4] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + policies and exceptions are optional descriptive records, not + remotely granted policy authority. Policy entries contain id, scope, + source and optional effectiveAt. Exception entries contain + findingId, reason, expiresAt and optional owner. Consumers MUST NOT + use a manifest-supplied policy or exception to suppress a security + finding without separate local authorization; expired or unowned + exceptions are not valid enforcement exceptions. + +4. Observations and Verification + + Declared means an operator assertion. Inferred means a tool derived + a conclusion, for example from a source scan. Observed means a + runtime measurement was performed and requires at least one evidence + reference. This producer-supplied label is not evidence + verification. Independently verified is a consumer result and is + deliberately not a manifest method value. + + Before accepting observed evidence, consumers MUST verify subject, + scope, acquisition time, freshness and provenance against locally + configured policy. They MUST impose a maximum measurement age even + when the manifest is still fresh. The reference consumer uses 86400 + seconds by default. Stale, unavailable, incomplete or conflicting + evidence cannot establish current protection. + + Consumers that accept signed evidence MUST use an existing signature + or attestation format with a documented application profile. They + MUST constrain algorithms, verify signatures with independently + configured issuer keys, and validate issuer authorization, audience, + purpose, subject and validity. Replay handling MUST bind accepted + statement identifiers or freshness checkpoints to the issuer and + subject in protected durable state. Loss of that state requires + explicit recovery; a newly issued wrapper MUST NOT refresh an old + measurement. [RFC8725] describes relevant JWT validation + requirements. No signature format or mandatory signature algorithm + is introduced here. + + A trusted signature authenticates what an issuer asserted, not the + truth or completeness of that assertion. Independent verification + requires a relying party to evaluate measurement evidence or repeat + the measurement through a trusted process. A consumer MUST report + which of signature authenticity, evidence integrity, measurement + validity and independent corroboration it actually checked. + + For TLS 1.3 [RFC8446], a claim of hybrid key establishment MUST + identify the negotiated group from a completed authenticated + connection. An offered group, cipher-suite name or self-reported + capability is insufficient. Certificate-chain validation, reference- + identity matching and handshake authentication MUST succeed. The + + + +Acosta Expires 14 April 2027 [Page 5] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + probe configuration, endpoint identity, time and group MUST be bound + to the evidence. Authentication algorithms MUST be assessed + separately: hybrid key establishment does not make an RSA or ECDSA + certificate post-quantum secure. + + A classical-only negotiation MUST be reported as classical. Testing + a classical-only peer distinguishes permitted fallback from a deny- + classical policy; it does not prove resistance to every active + downgrade attack. Implementations MUST rely on protocol-native + authenticated negotiation and MUST NOT construct hybrid cryptography + from manifest fields. + + The reference implementation accepts Ed25519 or ML-DSA-65 signatures + under an explicit local key-algorithm policy. ML-DSA-65 JWS uses + [RFC9964] with an empty context and pure ML-DSA, not HashML-DSA. Its + public-key import follows [RFC9881]. An existing signing authority + can issue this standard envelope without moving its private key. + Successful signature verification does not verify the signed + measurement. + +5. Compatibility and Monitoring + + Compatibility from profile intersection is only declared + compatibility. A consumer MUST NOT infer interoperability from + deviceClass alone. Empty inventories, unresolved firmware or + resource constraints and untested authentication trust yield unknown. + A definite protocol mismatch or absence of common declared algorithms + can be reported as a declared mismatch. Real interoperability + requires measurements on each relevant software, firmware and trust- + store combination. + + A gateway that terminates TLS creates separate hops. A successful + browser-to-gateway test MUST NOT establish gateway-to-device + protection. Offline or constrained devices SHOULD be represented + through an authenticated fleet gateway with explicit connectivity + constraints; missing telemetry is unknown. This document supplies no + device enrollment protocol and no guarantee of inventory coverage. + + Monitors SHOULD detect expired evidence, changed algorithms, + classical fallback, missing profiles and invalid snapshots. They + SHOULD preserve the last valid baseline when new input fails, + distinguish failure from recovery, and emit actionable, deduplicated + local alerts. State MUST be scoped to the subject and protected + against unauthorized modification and rollback to the extent provided + by the local platform. A manifest does not authorize a monitor to + change firmware, keys, software or network policy. + + + + + +Acosta Expires 14 April 2027 [Page 6] + +Internet-Draft Crypto-Agility Manifest October 2026 + + +6. Security and Privacy Considerations + + All public content is untrusted, including signed content from a + compromised issuer. Consumers MUST bound retrieval time, response + bytes, parsing, evidence size and retries, and defend against server- + side request forgery, DNS rebinding and unsafe redirects. Network + destinations MUST be independently authorized. Public discovery MUST + NOT automatically fetch private addresses, local file references or + issuer-supplied keys. Locally provisioned evidence directories MAY + be used by offline tools with path confinement. + + URLs, descriptive strings and constraints MUST NOT be executed or + interpreted as instructions. Public manifests SHOULD omit secrets, + internal hostnames, device identifiers and detailed vulnerability + inventories. Producers should assess the privacy impact of exposing + migration gaps and change cadence. Consumers MUST NOT equate scores, + declarations, signatures or successful sample measurements with + comprehensive security or regulatory compliance. + +7. IANA Considerations + + This document proposes provisional registration of the following + well-known URI under [RFC8615]. Registration is requested, not + assumed to have been approved. + + URI suffix: crypto-agility.json. Change controller: Leon Acosta + (leon@quantakrypto.com). Specification document: this document, + Sections 2 and 3. Status: provisional. Related information: HTTPS + on port 443, DNS hostnames, application/json. No additional path + components, query parameters or fragment identifiers are defined. + +8. Normative References + + [JSONSCHEMA] + JSON Schema, "JSON Schema: A Media Type for Describing + JSON Documents", 2020-12, work in progress, June 2022, + . + + [JSONVALIDATION] + JSON Schema, "JSON Schema Validation: A Vocabulary for + Structural Validation of JSON", 2020-12, work in progress, + June 2022, . + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", RFC 2119, DOI 10.17487/RFC2119, 1997, + . + + + + +Acosta Expires 14 April 2027 [Page 7] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + [RFC3339] Newman, G. K. A. C., "Date and Time on the Internet: + Timestamps", RFC 3339, DOI 10.17487/RFC3339, 2002, + . + + [RFC6234] Hansen, D. E. 3. A. T., "US Secure Hash Algorithms (SHA + and SHA-based HMAC and HKDF)", RFC 6234, + DOI 10.17487/RFC6234, 2011, + . + + [RFC6454] Barth, A., "The Web Origin Concept", RFC 6454, + DOI 10.17487/RFC6454, 2011, + . + + [RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC + 2119 Key Words", RFC 8174, DOI 10.17487/RFC8174, 2017, + . + + [RFC8259] Bray, T., "The JavaScript Object Notation (JSON) Data + Interchange Format", RFC 8259, DOI 10.17487/RFC8259, 2017, + . + + [RFC8615] Nottingham, M., "Well-Known Uniform Resource Identifiers + (URIs)", RFC 8615, DOI 10.17487/RFC8615, 2019, + . + + [RFC9110] Reschke, R. F. M. N. A. J., "HTTP Semantics", RFC 9110, + DOI 10.17487/RFC9110, 2022, + . + + [RFC9111] Reschke, R. F. M. N. A. J., "HTTP Caching", RFC 9111, + DOI 10.17487/RFC9111, 2022, + . + +9. Informative References + + [RFC8446] Rescorla, E., "The Transport Layer Security (TLS) Protocol + Version 1.3", RFC 8446, 2018, + . + + [RFC8725] Jones, Y. S. D. H. A. M., "JSON Web Token Best Current + Practices", RFC 8725, 2020, + . + + [RFC9881] Westerbaan, J. M. P. K. S. T. A. B. E., "Internet X.509 + Public Key Infrastructure -- Algorithm Identifiers for the + Module-Lattice-Based Digital Signature Algorithm (ML- + DSA)", RFC 9881, DOI 10.17487/RFC9881, October 2025, + . + + + +Acosta Expires 14 April 2027 [Page 8] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + [RFC9964] Steele, M. P. A. O., "ML-DSA for JSON Object Signing and + Encryption (JOSE) and CBOR Object Signing and Encryption + (COSE)", RFC 9964, DOI 10.17487/RFC9964, May 2026, + . + +Appendix A. JSON Schema + + This schema is normative. Cross-reference resolution, time ordering, + uniqueness of record identifiers, subject binding and evidence + verification also require the semantic checks specified above. The + schema is bundled; validators MUST NOT fetch schemas selected by an + untrusted manifest. + + { + "$schema":"https://json-schema.org/draft/2020-12/schema", + "title":"Crypto-Agility Manifest version 2", + "type":"object", + "additionalProperties":false, + "required": + [ + "version", + "manifestType", + "generatedAt", + "expiresAt", + "subject", + "observations", + "migration", + "profiles" + ], + "properties": + { + "version":{"const":2}, + "manifestType":{"const":"crypto-agility"}, + "generatedAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "expiresAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + + + +Acosta Expires 14 April 2027 [Page 9] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + "pattern": + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "subject": + { + "type":"object", + "additionalProperties":false, + "required":["kind","id"], + "properties": + { + "kind": + { + "enum": + [ + "origin", + "service", + "repository", + "artifact", + "device-fleet" + ] + }, + "id":{"type":"string","minLength":1,"maxLength":2048}, + "environment": + {"type":"string","minLength":1,"maxLength":2048}, + "commit":{"type":"string","minLength":1,"maxLength":2048} + } + }, + "observations": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","capability","value","method","observedAt"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "capability": + {"type":"string","minLength":1,"maxLength":2048}, + "value":{"enum":["supported","unsupported","unknown"]}, + "method":{"enum":["observed","inferred","declared"]}, + "observedAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + + + +Acosta Expires 14 April 2027 [Page 10] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + "pattern": + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + } + } + }, + "maxItems":256 + }, + "evidence": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","mediaType","digest","subjectId"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "uri": + { + "type":"string", + "format":"uri", + "minLength":1, + "maxLength":2048 + }, + "mediaType": + {"type":"string","minLength":1,"maxLength":2048}, + "digest": + { + "type":"object", + "additionalProperties":false, + "required":["algorithm","value"], + "properties": + { + "algorithm":{"enum":["sha-256","sha-384","sha-512"]}, + "value": + { + "type":"string", + "pattern":"^[0-9a-f]+$", + "minLength":1, + "maxLength":2048 + + + +Acosta Expires 14 April 2027 [Page 11] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + } + }, + "allOf": + [ + { + "if": + {"properties":{"algorithm":{"const":"sha-256"}}}, + "then": + { + "properties": + {"value":{"minLength":64,"maxLength":64}} + } + }, + { + "if": + {"properties":{"algorithm":{"const":"sha-384"}}}, + "then": + { + "properties": + {"value":{"minLength":96,"maxLength":96}} + } + }, + { + "if": + {"properties":{"algorithm":{"const":"sha-512"}}}, + "then": + { + "properties": + {"value":{"minLength":128,"maxLength":128}} + } + } + ] + }, + "subjectId": + {"type":"string","minLength":1,"maxLength":2048} + } + }, + "maxItems":256 + }, + "migration": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","usage","status"], + "properties": + + + +Acosta Expires 14 April 2027 [Page 12] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "usage": + { + "enum": + [ + "key-establishment", + "authentication", + "signature", + "encryption", + "other" + ] + }, + "algorithm": + {"type":"string","minLength":1,"maxLength":2048}, + "target":{"type":"string","minLength":1,"maxLength":2048}, + "status": + { + "enum": + [ + "unknown", + "planned", + "blocked", + "in-progress", + "validated", + "deployed" + ] + }, + "deadline": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "profileIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + + + +Acosta Expires 14 April 2027 [Page 13] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + "maxItems":256, + "uniqueItems":true + }, + "blockers": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256 + } + } + }, + "maxItems":256 + }, + "profiles": + { + "type":"array", + "minItems":1, + "items": + { + "type":"object", + "additionalProperties":false, + "required": + [ + "id", + "endpointRole", + "deviceClass", + "protocol", + "keyEstablishment", + "authentication", + "fallback" + ], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "endpointRole": + {"enum":["client","server","peer","gateway"]}, + "deviceClass": + { + "enum": + [ + "browser", + "mobile", + "desktop", + "server", + "iot", + "embedded", + "industrial", + "network", + + + +Acosta Expires 14 April 2027 [Page 14] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + "other" + ] + }, + "protocol": + {"type":"string","minLength":1,"maxLength":2048}, + "keyEstablishment": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "authentication": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "fallback": + {"enum":["deny-classical","allow-classical","unknown"]}, + "constraints": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256 + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + } + } + }, + "maxItems":64 + }, + "policies": + { + "type":"array", + "items": + { + "type":"object", + "required":["id","scope","source"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + + + +Acosta Expires 14 April 2027 [Page 15] + +Internet-Draft Crypto-Agility Manifest October 2026 + + + "scope":{"type":"string","minLength":1,"maxLength":2048}, + "source":{"type":"string","minLength":1,"maxLength":2048}, + "effectiveAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties":false + }, + "maxItems":256 + }, + "exceptions": + { + "type":"array", + "items": + { + "type":"object", + "required":["findingId","reason","expiresAt"], + "properties": + { + "findingId": + {"type":"string","minLength":1,"maxLength":2048}, + "reason":{"type":"string","minLength":1,"maxLength":2048}, + "owner":{"type":"string","minLength":1,"maxLength":2048}, + "expiresAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties":false + }, + "maxItems":256 + } + }, + "$id": + "https://quantakrypto.com/schema/crypto-agility-v2.schema.json" + } + + + + +Acosta Expires 14 April 2027 [Page 16] + +Internet-Draft Crypto-Agility Manifest October 2026 + + +Appendix B. Minimal Example + + This example has no runtime measurements. Its profile is a + declaration, not demonstrated compatibility or security. + + { + "version": 2, + "manifestType": "crypto-agility", + "generatedAt": "2026-10-11T10:00:00Z", + "expiresAt": "2026-10-12T10:00:00Z", + "subject": { + "kind": "origin", + "id": "https://example.org" + }, + "observations": [], + "migration": [], + "profiles": [ + { + "id": "service", + "endpointRole": "server", + "deviceClass": "server", + "protocol": "TLS 1.3", + "keyEstablishment": [], + "authentication": [], + "fallback": "unknown" + } + ] + } + +Author's Address + + Leon Acosta + QuantaKrypto + Email: leon@quantakrypto.com + + + + + + + + + + + + + + + + + +Acosta Expires 14 April 2027 [Page 17] diff --git a/draft/draft-acosta-crypto-agility-manifest-02.xml b/draft/draft-acosta-crypto-agility-manifest-02.xml new file mode 100644 index 0000000..e03ffdc --- /dev/null +++ b/draft/draft-acosta-crypto-agility-manifest-02.xml @@ -0,0 +1,617 @@ + + + + A Well-Known Crypto-Agility Manifest + + + QuantaKrypto +
+ leon@quantakrypto.com +
+
+ + + This document defines a small JSON discovery manifest for cryptographic migration assessment. It distinguishes declarations, runtime observations and consumer verification, and separates key establishment from authentication. It covers heterogeneous endpoint profiles and evidence references. It does not define cryptographic negotiation, an attestation format, a scoring system, or authority to modify systems. + +
+ +
+ Introduction + Cryptographic migration decisions need discoverable information about a specific system. A public manifest supplies declarations and evidence references; it cannot establish that every client, device or network path uses the declared algorithms. This individual proposal has no IETF consensus and does not claim universal post-quantum security. + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 + when, and only when, they appear in all capitals, as shown here. +
+
+ Discovery and Versioning + A producer publishes the manifest at /.well-known/crypto-agility.json on an HTTPS origin, using application/json + . An origin is a scheme, host and port tuple . This profile uses port 443 and DNS hostnames. Consumers MUST authenticate the HTTPS server and MUST NOT follow redirects automatically. Publication by an origin establishes only who served the declaration. + The relying party MUST configure the expected subject independently. For subject.kind origin, subject.id MUST equal the configured HTTPS origin serialized under . Other kinds require an independently configured mapping from the discovery origin to the exact subject.id. Consumers MUST NOT infer authority over sibling hosts, repositories, fleets or deployment environments from a shared publisher. + Version 2 replaces, and is not an additive extension of, version 1. Both use the same discovery location. Producers serve one representation; consumers MUST reject unsupported versions rather than reinterpret their fields. A version 1 readiness score or hybridKex declaration MUST NOT be upgraded into a version 2 runtime observation. There is no downgrade or content-negotiation protocol in this document. + Producers SHOULD supply ETag or Last-Modified validators and Cache-Control. Consumers SHOULD use conditional GET under and , honor no-store and revalidation directives, and impose a local maximum cache age. A 304 response reuses bytes but MUST NOT extend manifest, evidence or observation validity. HTTP errors and unavailable manifests yield unknown posture, never unsupported cryptography. + Optional external change feeds MAY trigger a bounded refresh of the configured discovery origin. This document defines no feed URL, event schema, signing scheme or hash chain. Notifications MUST NOT replace snapshot validation, authorize execution, or redirect evidence fetching. Polling with backoff remains a recovery mechanism. +
+
+ Data Model + The JSON object MUST conform to the schema in Appendix A, using JSON Schema 2020-12 + . Strings are case-sensitive. Unknown properties and duplicate JSON member names MUST be rejected. Producers MUST use UTF-8 . Documents MUST NOT exceed 1048576 octets or 16 nested containers. Each array has at most 256 entries, except profiles, which has at most 64; strings have at most 2048 Unicode code points unless the schema sets a stricter bound. + Timestamps use a restricted form: uppercase T and Z, UTC, zero through six fractional-second digits, and no leap-second value. Consumers MUST validate calendar values and reject expiresAt less than or equal to generatedAt. They MUST NOT treat expired data as current. A local clock-skew allowance MAY be used; the reference consumer permits at most 300 seconds for future timestamps. + Every id MUST be unique within its collection. References in evidenceIds and profileIds MUST resolve within this manifest. Missing references, contradictory supported and unsupported values for one capability, and future observations MUST produce findings rather than a passing security decision. + The required version is the integer 2 and manifestType is crypto-agility. generatedAt identifies document generation and expiresAt its validity limit. Neither timestamp establishes when any endpoint was measured. subject contains required kind and id; optional environment and commit narrow descriptive context and do not prove deployed code. + observations is a required array, possibly empty. Each entry contains id, capability, value (supported, unsupported or unknown), method (declared, inferred or observed), observedAt and optional evidenceIds. For declared and inferred methods, observedAt is when the assertion was recorded. Capability names are descriptive identifiers; this document defines no global capability registry. Producers SHOULD use a protocol-specific registered algorithm identifier or an unambiguous implementation name where available. + evidence is an optional array. Each entry contains id, mediaType, digest and subjectId; uri is optional. subjectId MUST equal manifest subject.id. The digest object contains algorithm (sha-256, sha-384 or sha-512) and the exact lowercase hexadecimal value, respectively 64, 96 or 128 digits . Hashing covers the complete evidence bytes before parsing or reserialization; HTTP content codings, if used, are removed first. Consumers MUST verify the digest before interpreting evidence. A digest match establishes byte integrity relative to the declaration, not authenticity. + profiles is a nonempty array of declared endpoint capability classes. Each contains id, endpointRole (client, server, peer or gateway), deviceClass, protocol, keyEstablishment, authentication and fallback (deny-classical, allow-classical or unknown). Empty algorithm arrays mean unspecified inventory, not demonstrated absence of support. Optional constraints are human-readable limitations; evidenceIds link supporting evidence. The schema lists device classes including browsers, mobile devices, servers, IoT, embedded and industrial devices. + migration is a required array, possibly empty. Each entry contains id, usage and status. Status values are unknown, planned, blocked, in-progress, validated and deployed. Optional algorithm, target, deadline, profileIds, evidenceIds and blockers describe the plan. A producer claiming validated or deployed MUST supply evidenceIds; the consumer MUST independently verify relevant validation or deployment evidence before relying on that claim. + policies and exceptions are optional descriptive records, not remotely granted policy authority. Policy entries contain id, scope, source and optional effectiveAt. Exception entries contain findingId, reason, expiresAt and optional owner. Consumers MUST NOT use a manifest-supplied policy or exception to suppress a security finding without separate local authorization; expired or unowned exceptions are not valid enforcement exceptions. +
+
+ Observations and Verification + Declared means an operator assertion. Inferred means a tool derived a conclusion, for example from a source scan. Observed means a runtime measurement was performed and requires at least one evidence reference. This producer-supplied label is not evidence verification. Independently verified is a consumer result and is deliberately not a manifest method value. + Before accepting observed evidence, consumers MUST verify subject, scope, acquisition time, freshness and provenance against locally configured policy. They MUST impose a maximum measurement age even when the manifest is still fresh. The reference consumer uses 86400 seconds by default. Stale, unavailable, incomplete or conflicting evidence cannot establish current protection. + Consumers that accept signed evidence MUST use an existing signature or attestation format with a documented application profile. They MUST constrain algorithms, verify signatures with independently configured issuer keys, and validate issuer authorization, audience, purpose, subject and validity. Replay handling MUST bind accepted statement identifiers or freshness checkpoints to the issuer and subject in protected durable state. Loss of that state requires explicit recovery; a newly issued wrapper MUST NOT refresh an old measurement. describes relevant JWT validation requirements. No signature format or mandatory signature algorithm is introduced here. + A trusted signature authenticates what an issuer asserted, not the truth or completeness of that assertion. Independent verification requires a relying party to evaluate measurement evidence or repeat the measurement through a trusted process. A consumer MUST report which of signature authenticity, evidence integrity, measurement validity and independent corroboration it actually checked. + For TLS 1.3 , a claim of hybrid key establishment MUST identify the negotiated group from a completed authenticated connection. An offered group, cipher-suite name or self-reported capability is insufficient. Certificate-chain validation, reference-identity matching and handshake authentication MUST succeed. The probe configuration, endpoint identity, time and group MUST be bound to the evidence. Authentication algorithms MUST be assessed separately: hybrid key establishment does not make an RSA or ECDSA certificate post-quantum secure. + A classical-only negotiation MUST be reported as classical. Testing a classical-only peer distinguishes permitted fallback from a deny-classical policy; it does not prove resistance to every active downgrade attack. Implementations MUST rely on protocol-native authenticated negotiation and MUST NOT construct hybrid cryptography from manifest fields. + The reference implementation accepts Ed25519 or ML-DSA-65 signatures under an explicit local key-algorithm policy. ML-DSA-65 JWS uses with an empty context and pure ML-DSA, not HashML-DSA. Its public-key import follows . An existing signing authority can issue this standard envelope without moving its private key. Successful signature verification does not verify the signed measurement. +
+
+ Compatibility and Monitoring + Compatibility from profile intersection is only declared compatibility. A consumer MUST NOT infer interoperability from deviceClass alone. Empty inventories, unresolved firmware or resource constraints and untested authentication trust yield unknown. A definite protocol mismatch or absence of common declared algorithms can be reported as a declared mismatch. Real interoperability requires measurements on each relevant software, firmware and trust-store combination. + A gateway that terminates TLS creates separate hops. A successful browser-to-gateway test MUST NOT establish gateway-to-device protection. Offline or constrained devices SHOULD be represented through an authenticated fleet gateway with explicit connectivity constraints; missing telemetry is unknown. This document supplies no device enrollment protocol and no guarantee of inventory coverage. + Monitors SHOULD detect expired evidence, changed algorithms, classical fallback, missing profiles and invalid snapshots. They SHOULD preserve the last valid baseline when new input fails, distinguish failure from recovery, and emit actionable, deduplicated local alerts. State MUST be scoped to the subject and protected against unauthorized modification and rollback to the extent provided by the local platform. A manifest does not authorize a monitor to change firmware, keys, software or network policy. +
+
+ Security and Privacy Considerations + All public content is untrusted, including signed content from a compromised issuer. Consumers MUST bound retrieval time, response bytes, parsing, evidence size and retries, and defend against server-side request forgery, DNS rebinding and unsafe redirects. Network destinations MUST be independently authorized. Public discovery MUST NOT automatically fetch private addresses, local file references or issuer-supplied keys. Locally provisioned evidence directories MAY be used by offline tools with path confinement. + URLs, descriptive strings and constraints MUST NOT be executed or interpreted as instructions. Public manifests SHOULD omit secrets, internal hostnames, device identifiers and detailed vulnerability inventories. Producers should assess the privacy impact of exposing migration gaps and change cadence. Consumers MUST NOT equate scores, declarations, signatures or successful sample measurements with comprehensive security or regulatory compliance. +
+
+ IANA Considerations + This document proposes provisional registration of the following well-known URI under . Registration is requested, not assumed to have been approved. + URI suffix: crypto-agility.json. Change controller: Leon Acosta (leon@quantakrypto.com). Specification document: this document, Sections 2 and 3. Status: provisional. Related information: HTTPS on port 443, DNS hostnames, application/json. No additional path components, query parameters or fragment identifiers are defined. +
+
+ + + Normative References + + + Key words for use in RFCs to Indicate Requirement Levels + + + + + + + + + Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words + + + + + + + + + The JavaScript Object Notation (JSON) Data Interchange Format + + + + + + + + + Date and Time on the Internet: Timestamps + + + + + + + + + Well-Known Uniform Resource Identifiers (URIs) + + + + + + + + + HTTP Semantics + + + + + + + + + HTTP Caching + + + + + + + + + The Web Origin Concept + + + + + + + + + US Secure Hash Algorithms (SHA and SHA-based HMAC and HKDF) + + + + + + + + + JSON Schema: A Media Type for Describing JSON Documents + + JSON Schema + + + + 2020-12, work in progress + + + + JSON Schema Validation: A Vocabulary for Structural Validation of JSON + + JSON Schema + + + + 2020-12, work in progress + + + + Informative References + + + The Transport Layer Security (TLS) Protocol Version 1.3 + + + + + + + + JSON Web Token Best Current Practices + + + + + + + + ML-DSA for JSON Object Signing and Encryption (JOSE) and CBOR Object Signing and Encryption (COSE) + + + + + + + + + Internet X.509 Public Key Infrastructure -- Algorithm Identifiers for the Module-Lattice-Based Digital Signature Algorithm (ML-DSA) + + + + + + + +
+ JSON Schema + This schema is normative. Cross-reference resolution, time ordering, uniqueness of record identifiers, subject binding and evidence verification also require the semantic checks specified above. The schema is bundled; validators MUST NOT fetch schemas selected by an untrusted manifest. + { + "$schema":"https://json-schema.org/draft/2020-12/schema", + "title":"Crypto-Agility Manifest version 2", + "type":"object", + "additionalProperties":false, + "required": + [ + "version", + "manifestType", + "generatedAt", + "expiresAt", + "subject", + "observations", + "migration", + "profiles" + ], + "properties": + { + "version":{"const":2}, + "manifestType":{"const":"crypto-agility"}, + "generatedAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "expiresAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "subject": + { + "type":"object", + "additionalProperties":false, + "required":["kind","id"], + "properties": + { + "kind": + { + "enum": + [ + "origin", + "service", + "repository", + "artifact", + "device-fleet" + ] + }, + "id":{"type":"string","minLength":1,"maxLength":2048}, + "environment": + {"type":"string","minLength":1,"maxLength":2048}, + "commit":{"type":"string","minLength":1,"maxLength":2048} + } + }, + "observations": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","capability","value","method","observedAt"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "capability": + {"type":"string","minLength":1,"maxLength":2048}, + "value":{"enum":["supported","unsupported","unknown"]}, + "method":{"enum":["observed","inferred","declared"]}, + "observedAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + } + } + }, + "maxItems":256 + }, + "evidence": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","mediaType","digest","subjectId"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "uri": + { + "type":"string", + "format":"uri", + "minLength":1, + "maxLength":2048 + }, + "mediaType": + {"type":"string","minLength":1,"maxLength":2048}, + "digest": + { + "type":"object", + "additionalProperties":false, + "required":["algorithm","value"], + "properties": + { + "algorithm":{"enum":["sha-256","sha-384","sha-512"]}, + "value": + { + "type":"string", + "pattern":"^[0-9a-f]+$", + "minLength":1, + "maxLength":2048 + } + }, + "allOf": + [ + { + "if": + {"properties":{"algorithm":{"const":"sha-256"}}}, + "then": + { + "properties": + {"value":{"minLength":64,"maxLength":64}} + } + }, + { + "if": + {"properties":{"algorithm":{"const":"sha-384"}}}, + "then": + { + "properties": + {"value":{"minLength":96,"maxLength":96}} + } + }, + { + "if": + {"properties":{"algorithm":{"const":"sha-512"}}}, + "then": + { + "properties": + {"value":{"minLength":128,"maxLength":128}} + } + } + ] + }, + "subjectId": + {"type":"string","minLength":1,"maxLength":2048} + } + }, + "maxItems":256 + }, + "migration": + { + "type":"array", + "items": + { + "type":"object", + "additionalProperties":false, + "required":["id","usage","status"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "usage": + { + "enum": + [ + "key-establishment", + "authentication", + "signature", + "encryption", + "other" + ] + }, + "algorithm": + {"type":"string","minLength":1,"maxLength":2048}, + "target":{"type":"string","minLength":1,"maxLength":2048}, + "status": + { + "enum": + [ + "unknown", + "planned", + "blocked", + "in-progress", + "validated", + "deployed" + ] + }, + "deadline": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "profileIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "blockers": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256 + } + } + }, + "maxItems":256 + }, + "profiles": + { + "type":"array", + "minItems":1, + "items": + { + "type":"object", + "additionalProperties":false, + "required": + [ + "id", + "endpointRole", + "deviceClass", + "protocol", + "keyEstablishment", + "authentication", + "fallback" + ], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "endpointRole": + {"enum":["client","server","peer","gateway"]}, + "deviceClass": + { + "enum": + [ + "browser", + "mobile", + "desktop", + "server", + "iot", + "embedded", + "industrial", + "network", + "other" + ] + }, + "protocol": + {"type":"string","minLength":1,"maxLength":2048}, + "keyEstablishment": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "authentication": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + }, + "fallback": + {"enum":["deny-classical","allow-classical","unknown"]}, + "constraints": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256 + }, + "evidenceIds": + { + "type":"array", + "items":{"type":"string","minLength":1,"maxLength":2048}, + "maxItems":256, + "uniqueItems":true + } + } + }, + "maxItems":64 + }, + "policies": + { + "type":"array", + "items": + { + "type":"object", + "required":["id","scope","source"], + "properties": + { + "id":{"type":"string","minLength":1,"maxLength":2048}, + "scope":{"type":"string","minLength":1,"maxLength":2048}, + "source":{"type":"string","minLength":1,"maxLength":2048}, + "effectiveAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties":false + }, + "maxItems":256 + }, + "exceptions": + { + "type":"array", + "items": + { + "type":"object", + "required":["findingId","reason","expiresAt"], + "properties": + { + "findingId": + {"type":"string","minLength":1,"maxLength":2048}, + "reason":{"type":"string","minLength":1,"maxLength":2048}, + "owner":{"type":"string","minLength":1,"maxLength":2048}, + "expiresAt": + { + "type":"string", + "format":"date-time", + "minLength":1, + "maxLength":2048, + "pattern": +"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties":false + }, + "maxItems":256 + } + }, + "$id": + "https://quantakrypto.com/schema/crypto-agility-v2.schema.json" +} +
+
+ Minimal Example + This example has no runtime measurements. Its profile is a declaration, not demonstrated compatibility or security. + { + "version": 2, + "manifestType": "crypto-agility", + "generatedAt": "2026-10-11T10:00:00Z", + "expiresAt": "2026-10-12T10:00:00Z", + "subject": { + "kind": "origin", + "id": "https://example.org" + }, + "observations": [], + "migration": [], + "profiles": [ + { + "id": "service", + "endpointRole": "server", + "deviceClass": "server", + "protocol": "TLS 1.3", + "keyEstablishment": [], + "authentication": [], + "fallback": "unknown" + } + ] +} +
+
+
\ No newline at end of file diff --git a/examples/crypto-agility-v2.json b/examples/crypto-agility-v2.json new file mode 100644 index 0000000..63e333d --- /dev/null +++ b/examples/crypto-agility-v2.json @@ -0,0 +1,72 @@ +{ + "version": 2, + "manifestType": "crypto-agility", + "generatedAt": "2026-10-11T10:00:00Z", + "expiresAt": "2026-10-18T10:00:00Z", + "subject": { + "kind": "service", + "id": "https://example.org", + "environment": "production" + }, + "observations": [ + { + "id": "obs-tls-01", + "capability": "TLS hybrid key establishment", + "value": "supported", + "method": "declared", + "observedAt": "2026-10-11T09:00:00Z", + "evidenceIds": [] + } + ], + "evidence": [], + "migration": [ + { + "id": "mig-auth-01", + "usage": "authentication", + "algorithm": "ECDSA", + "target": "PQC-capable authentication", + "status": "blocked", + "profileIds": [ + "modern-browser", + "legacy-sensor" + ], + "blockers": [ + "legacy-sensor firmware lacks PQC verification" + ] + } + ], + "profiles": [ + { + "id": "modern-browser", + "endpointRole": "client", + "deviceClass": "browser", + "protocol": "TLS 1.3", + "keyEstablishment": [ + "X25519MLKEM768" + ], + "authentication": [ + "ECDSA" + ], + "fallback": "allow-classical", + "constraints": [] + }, + { + "id": "legacy-sensor", + "endpointRole": "client", + "deviceClass": "iot", + "protocol": "TLS 1.3", + "keyEstablishment": [ + "X25519" + ], + "authentication": [ + "ECDSA" + ], + "fallback": "unknown", + "constraints": [ + "firmware update required" + ] + } + ], + "policies": [], + "exceptions": [] +} diff --git a/examples/minimal-v2.json b/examples/minimal-v2.json new file mode 100644 index 0000000..6fbc19d --- /dev/null +++ b/examples/minimal-v2.json @@ -0,0 +1,23 @@ +{ + "version": 2, + "manifestType": "crypto-agility", + "generatedAt": "2026-10-11T10:00:00Z", + "expiresAt": "2026-10-12T10:00:00Z", + "subject": { + "kind": "origin", + "id": "https://example.org" + }, + "observations": [], + "migration": [], + "profiles": [ + { + "id": "service", + "endpointRole": "server", + "deviceClass": "server", + "protocol": "TLS 1.3", + "keyEstablishment": [], + "authentication": [], + "fallback": "unknown" + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c8eb483 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,756 @@ +{ + "name": "crypto-agility-interop-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crypto-agility-interop-tests", + "devDependencies": { + "@ietf-tools/idnits": "3.1.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } + }, + "node_modules/@ietf-tools/idnits": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@ietf-tools/idnits/-/idnits-3.1.0.tgz", + "integrity": "sha512-buWTvfw9EU+fm5PyaI2dv07FlM0lcCQNMyXPiRxaLNdu7yDDmjJvU5auoRF8/e4Iv4/dBffRvx8atbBVohfXrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "chalk": "5.6.2", + "entities": "7.0.0", + "fast-xml-parser": "5.2.5", + "jschardet": "3.0.0", + "listr2": "9.0.4", + "lodash-es": "4.17.21", + "luxon": "3.7.2", + "semver": "7.7.2", + "yargs": "18.0.0" + }, + "bin": { + "idnits": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ] + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, + "node_modules/jschardet": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz", + "integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==", + "dev": true, + "license": "LGPL-2.1+", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", + "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..14b7365 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "devDependencies": { + "@ietf-tools/idnits": "3.1.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + }, + "name": "crypto-agility-interop-tests", + "private": true, + "description": "Development-only independent schema interoperability gates", + "overrides": { + "fast-xml-parser": "5.11.2" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..40cada0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +jsonschema[format]==4.26.0 +cryptography==46.0.7 +PyJWT==2.15.1 +xml2rfc==3.34.1 diff --git a/schema/crypto-agility-v2.schema.json b/schema/crypto-agility-v2.schema.json new file mode 100644 index 0000000..df31847 --- /dev/null +++ b/schema/crypto-agility-v2.schema.json @@ -0,0 +1,500 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Crypto-Agility Manifest version 2", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "manifestType", + "generatedAt", + "expiresAt", + "subject", + "observations", + "migration", + "profiles" + ], + "properties": { + "version": { + "const": 2 + }, + "manifestType": { + "const": "crypto-agility" + }, + "generatedAt": { + "type": "string", + "format": "date-time", + "minLength": 1, + "maxLength": 2048, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "minLength": 1, + "maxLength": 2048, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "enum": [ + "origin", + "service", + "repository", + "artifact", + "device-fleet" + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "environment": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "commit": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + } + }, + "observations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "capability", + "value", + "method", + "observedAt" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "capability": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "value": { + "enum": [ + "supported", + "unsupported", + "unknown" + ] + }, + "method": { + "enum": [ + "observed", + "inferred", + "declared" + ] + }, + "observedAt": { + "type": "string", + "format": "date-time", + "minLength": 1, + "maxLength": 2048, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "evidenceIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256, + "uniqueItems": true + } + } + }, + "maxItems": 256 + }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "mediaType", + "digest", + "subjectId" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "uri": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048 + }, + "mediaType": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "digest": { + "type": "object", + "additionalProperties": false, + "required": [ + "algorithm", + "value" + ], + "properties": { + "algorithm": { + "enum": [ + "sha-256", + "sha-384", + "sha-512" + ] + }, + "value": { + "type": "string", + "pattern": "^[0-9a-f]+$", + "minLength": 1, + "maxLength": 2048 + } + }, + "allOf": [ + { + "if": { + "properties": { + "algorithm": { + "const": "sha-256" + } + } + }, + "then": { + "properties": { + "value": { + "minLength": 64, + "maxLength": 64 + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "sha-384" + } + } + }, + "then": { + "properties": { + "value": { + "minLength": 96, + "maxLength": 96 + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "sha-512" + } + } + }, + "then": { + "properties": { + "value": { + "minLength": 128, + "maxLength": 128 + } + } + } + } + ] + }, + "subjectId": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + } + }, + "maxItems": 256 + }, + "migration": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "usage", + "status" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "usage": { + "enum": [ + "key-establishment", + "authentication", + "signature", + "encryption", + "other" + ] + }, + "algorithm": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "target": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "status": { + "enum": [ + "unknown", + "planned", + "blocked", + "in-progress", + "validated", + "deployed" + ] + }, + "deadline": { + "type": "string", + "format": "date-time", + "minLength": 1, + "maxLength": 2048, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + }, + "profileIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256, + "uniqueItems": true + }, + "evidenceIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256, + "uniqueItems": true + }, + "blockers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256 + } + } + }, + "maxItems": 256 + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "endpointRole", + "deviceClass", + "protocol", + "keyEstablishment", + "authentication", + "fallback" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "endpointRole": { + "enum": [ + "client", + "server", + "peer", + "gateway" + ] + }, + "deviceClass": { + "enum": [ + "browser", + "mobile", + "desktop", + "server", + "iot", + "embedded", + "industrial", + "network", + "other" + ] + }, + "protocol": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "keyEstablishment": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256, + "uniqueItems": true + }, + "authentication": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256, + "uniqueItems": true + }, + "fallback": { + "enum": [ + "deny-classical", + "allow-classical", + "unknown" + ] + }, + "constraints": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256 + }, + "evidenceIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maxItems": 256, + "uniqueItems": true + } + } + }, + "maxItems": 64 + }, + "policies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "scope", + "source" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "scope": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "effectiveAt": { + "type": "string", + "format": "date-time", + "minLength": 1, + "maxLength": 2048, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties": false + }, + "maxItems": 256 + }, + "exceptions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "findingId", + "reason", + "expiresAt" + ], + "properties": { + "findingId": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "minLength": 1, + "maxLength": 2048, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:[0-5]\\d(?:\\.\\d{1,6})?Z$" + } + }, + "additionalProperties": false + }, + "maxItems": 256 + } + }, + "$id": "https://quantakrypto.com/schema/crypto-agility-v2.schema.json" +} diff --git a/scripts/build-test-openssl.sh b/scripts/build-test-openssl.sh new file mode 100644 index 0000000..be4822e --- /dev/null +++ b/scripts/build-test-openssl.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Test dependency only. Build an immutable upstream OpenSSL 3.5.9 commit. +set -euo pipefail +destination="${1:?build directory required}" +revision=45e844fa2a14ec92d146bd8f5778ac130b6625fb +mkdir -p "$destination" +git -C "$destination" init -q +git -C "$destination" fetch --depth 1 https://github.com/openssl/openssl.git "$revision" +git -C "$destination" checkout --detach FETCH_HEAD +test "$(git -C "$destination" rev-parse HEAD)" = "$revision" +cd "$destination" +./Configure no-shared no-tests +make -j2 +./apps/openssl version diff --git a/scripts/check-tests.py b/scripts/check-tests.py new file mode 100644 index 0000000..113c9f2 --- /dev/null +++ b/scripts/check-tests.py @@ -0,0 +1,12 @@ +"""Acceptance runner: failed, errored or skipped tests fail the gate.""" +from pathlib import Path +import sys +import unittest + +root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(root)) +suite = unittest.defaultTestLoader.discover(str(root / "tests")) +result = unittest.TextTestRunner(verbosity=2).run(suite) +if result.skipped: + print("Acceptance requires zero skipped tests", file=sys.stderr) +raise SystemExit(0 if result.wasSuccessful() and not result.skipped else 1) diff --git a/tests/fixtures/node_jws_issuer.mjs b/tests/fixtures/node_jws_issuer.mjs new file mode 100644 index 0000000..38057b7 --- /dev/null +++ b/tests/fixtures/node_jws_issuer.mjs @@ -0,0 +1,30 @@ +// Independent RFC 7515 compact JWS producer/crypto verifier used by Python tests. +// No production Python serializer, signing helper, or trust code is imported. +// Keys are ephemeral test keys. This fixture is not an attestation authority. +import { generateKeyPairSync, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +const request = JSON.parse(readFileSync(0, 'utf8')); +const encode = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url'); +if (request.operation === 'sign') { + const privateKey = request.privateJwk + ? createPrivateKey({ key: request.privateJwk, format: 'jwk' }) + : generateKeyPairSync('ed25519').privateKey; + const protectedHeader = encode(request.header); + // Whitespace and property order intentionally need not match Python output. + const payload = encode(request.payloadText ?? JSON.stringify(request.claims, null, 2)); + const signingInput = `${protectedHeader}.${payload}`; + const signature = sign(null, Buffer.from(signingInput, 'ascii'), privateKey).toString('base64url'); + process.stdout.write(JSON.stringify({ token: `${signingInput}.${signature}`, + publicJwk: createPublicKey(privateKey).export({ format: 'jwk' }), + privateJwk: privateKey.export({ format: 'jwk' }) })); +} else if (request.operation === 'verify') { + const parts = request.token.split('.'); + if (parts.length !== 3) throw new Error('compact JWS required'); + const publicKey = createPublicKey({ key: request.publicJwk, format: 'jwk' }); + const verified = verify(null, Buffer.from(`${parts[0]}.${parts[1]}`, 'ascii'), + publicKey, Buffer.from(parts[2], 'base64url')); + process.stdout.write(JSON.stringify({ verified })); +} else { + throw new Error('unknown operation'); +} diff --git a/tests/fixtures/node_manifest_consumer.mjs b/tests/fixtures/node_manifest_consumer.mjs new file mode 100644 index 0000000..155fb7a --- /dev/null +++ b/tests/fixtures/node_manifest_consumer.mjs @@ -0,0 +1,11 @@ +// Independent JSON Schema 2020-12 consumer. This checks structural conformance, +// not evidence truth, issuer trust, negotiation, or semantic freshness policy. +import { readFileSync } from 'node:fs'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +const input = JSON.parse(readFileSync(0, 'utf8')); +const ajv = new Ajv2020({ strict: false, allErrors: false, validateFormats: true }); +addFormats(ajv); +const validate = ajv.compile(input.schema); +process.stdout.write(JSON.stringify(input.cases.map(item => ({ id: item.id, valid: validate(item.manifest) })))); diff --git a/tests/fixtures/qscan-project/sample.js b/tests/fixtures/qscan-project/sample.js new file mode 100644 index 0000000..1ed5c1a --- /dev/null +++ b/tests/fixtures/qscan-project/sample.js @@ -0,0 +1,2 @@ +const crypto = require('node:crypto'); +crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); diff --git a/tests/fixtures/qscan-v1.json b/tests/fixtures/qscan-v1.json new file mode 100644 index 0000000..22a33de --- /dev/null +++ b/tests/fixtures/qscan-v1.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "manifestType": "crypto-agility", + "generatedAt": "2026-10-11T00:00:00Z", + "generator": { + "name": "qScan", + "version": "0.12.0" + }, + "subject": { + "root": ".", + "repository": "quantakrypto/test-fixture", + "commit": "1111111111111111111111111111111111111111" + }, + "posture": { + "readinessScore": 84, + "hybridKexInUse": true, + "quantumVulnerable": { + "total": 1, + "bySeverity": { + "critical": 0, + "high": 1, + "medium": 0, + "low": 0, + "info": 0 + } + }, + "hndlExposedCount": 1 + }, + "cbomSummary": { + "serialNumber": "urn:uuid:a6521fb2-36e8-44ff-8bb3-0e78cbb03a84", + "assetCount": 1, + "algorithmFamilies": [ + { + "family": "RSA", + "count": 1, + "quantumVulnerable": true + } + ] + }, + "policy": { + "source": "NIST IR 8547 (transition to post-quantum cryptography standards)", + "deprecateClassicalAfter": 2030, + "disallowClassicalAfter": 2035, + "transitionDeadline": null, + "citation": "NIST IR 8547 (transition to post-quantum cryptography standards)" + } +} diff --git a/tests/mldsa_sign.mjs b/tests/mldsa_sign.mjs new file mode 100644 index 0000000..e7564fe --- /dev/null +++ b/tests/mldsa_sign.mjs @@ -0,0 +1,18 @@ +// Independent JWS producer using maintained Node.js crypto (RFC9964 pure ML-DSA). +import { generateKeyPairSync, sign, createHash } from 'node:crypto' +let input = '' +for await (const part of process.stdin) input += part +const { claims, header, mode = 'normal', keyType = 'ml-dsa-65' } = JSON.parse(input) +const { privateKey, publicKey } = generateKeyPairSync(keyType) +const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url') +const unsigned = `${encode(header)}.${encode(claims)}` +const message = mode === 'prehashed' ? createHash('sha256').update(unsigned).digest() : Buffer.from(unsigned) +const options = mode === 'context' ? { key: privateKey, context: Buffer.from('not-empty') } : privateKey +const signature = sign(null, message, options) +process.stdout.write(JSON.stringify({ + token: `${unsigned}.${signature.toString('base64url')}`, + publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), + algorithm: publicKey.asymmetricKeyType, + node: process.version, + openssl: process.versions.openssl +})) diff --git a/tests/qscan_interop.mjs b/tests/qscan_interop.mjs new file mode 100644 index 0000000..c7fca6e --- /dev/null +++ b/tests/qscan_interop.mjs @@ -0,0 +1,29 @@ +// Exercise qScan's real scanner and v1 producer from a pinned sibling checkout. +// Built dist is used in CI. Node 24 can execute sources offline in local review. +import { registerHooks } from 'node:module'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const root = resolve(process.env.QSCAN_ROOT); +const built = existsSync(resolve(root, 'packages/core/dist/crypto-agility.js')); +if (!built) { + registerHooks({ resolve(specifier, context, next) { + if (specifier.endsWith('.js') && context.parentURL?.startsWith('file:')) { + const url = new URL(specifier.slice(0, -3) + '.ts', context.parentURL); + if (existsSync(fileURLToPath(url))) return { url: url.href, shortCircuit: true }; + } + return next(specifier, context); + } }); +} +const entry = name => pathToFileURL(resolve(root, 'packages/core', built ? 'dist' : 'src', name + (built ? '.js' : '.ts'))).href; +const { scan } = await import(entry('scan')); +const { buildCryptoAgilityManifest } = await import(entry('crypto-agility')); +const result = await scan({ root: resolve('tests/fixtures/qscan-project') }); +if (result.findings.length < 1) throw new Error('qScan did not detect fixture RSA'); +// Explicit stable producer inputs: location normalization does not change findings. +result.root = '.'; +const manifest = buildCryptoAgilityManifest(result, { + generatedAt: '2026-10-11T00:00:00Z', repository: 'quantakrypto/test-fixture', + commit: '1'.repeat(40), hybridKexInUse: true, +}); +process.stdout.write(JSON.stringify(manifest, null, 2) + '\n'); diff --git a/tests/test_adversarial.py b/tests/test_adversarial.py new file mode 100644 index 0000000..34d64f7 --- /dev/null +++ b/tests/test_adversarial.py @@ -0,0 +1,48 @@ +"""Adversarial and regression tests for the offline assessment boundary.""" +import copy +import datetime as dt +import json +import tempfile +import unittest +from pathlib import Path +from tools.monitor import snapshot +from tools.verifier import assess +from tools.state import update_state + +ROOT=Path(__file__).resolve().parents[1] +SCHEMA=json.loads((ROOT/"schema/crypto-agility-v2.schema.json").read_text()) +EXAMPLE=json.loads((ROOT/"examples/crypto-agility-v2.json").read_text()) +NOW=dt.datetime(2026,10,12,tzinfo=dt.timezone.utc) + +class AdversarialTests(unittest.TestCase): + def test_invalid_profile_type_does_not_crash_monitor(self): + m=copy.deepcopy(EXAMPLE);m["profiles"]="malicious" + report=snapshot(m,SCHEMA,NOW) + self.assertEqual(report["assessment"]["status"],"invalid") + self.assertEqual(report["pairs"],[]) + def test_invalid_evidence_structure(self): + m=copy.deepcopy(EXAMPLE);m["evidence"]={"not":"an array"} + self.assertEqual(assess(m,SCHEMA,NOW)["status"],"invalid") + def test_future_generated_at(self): + m=copy.deepcopy(EXAMPLE);m["generatedAt"]="2027-01-01T00:00:00Z";m["expiresAt"]="2027-01-02T00:00:00Z" + self.assertIn("future-manifest",{f["code"] for f in assess(m,SCHEMA,NOW)["findings"]}) + def test_duplicate_ids(self): + m=copy.deepcopy(EXAMPLE);m["profiles"].append(copy.deepcopy(m["profiles"][0])) + self.assertIn("duplicate-id",{f["code"] for f in assess(m,SCHEMA,NOW)["findings"]}) + def test_missing_evidence_reference(self): + m=copy.deepcopy(EXAMPLE);m["observations"][0]["evidenceIds"]=["nonexistent"] + self.assertIn("missing-evidence",{f["code"] for f in assess(m,SCHEMA,NOW)["findings"]}) + def test_unsupported_version(self): + m=copy.deepcopy(EXAMPLE);m["version"]=3 + self.assertEqual(assess(m,SCHEMA,NOW)["status"],"invalid") + def test_invalid_state_is_not_persisted(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/"state.json" + update_state(p,{"subject":"a","assessment":{"status":"unverified","findings":[]},"pairs":[]}) + original=p.read_bytes() + with self.assertRaises(ValueError): + update_state(p,{"subject":"a","assessment":{"status":"invalid","findings":[]},"pairs":[]}) + self.assertEqual(p.read_bytes(),original) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_attestation.py b/tests/test_attestation.py new file mode 100644 index 0000000..9780392 --- /dev/null +++ b/tests/test_attestation.py @@ -0,0 +1,130 @@ +import base64 +import copy +import datetime as dt +import json +from pathlib import Path +import tempfile +import unittest +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from tools.attestation import ReplayStore, TOKEN_TYPE, verify_envelope + +NOW = dt.datetime(2026, 10, 12, tzinfo=dt.timezone.utc) + + +class AttestationTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.store = ReplayStore(Path(self.directory.name) / "replay.json") + self.key = Ed25519PrivateKey.generate() + public = self.key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + self.policy = {"keys": {"probe-1": base64.urlsafe_b64encode(public).rstrip(b"=").decode()}, + "subjects": ["https://example.org"], "audiences": ["monitor"], + "purposes": ["pqc-observation"]} + self.trust = {"issuers": {"probe": self.policy}} + self.claims = {"iss": "probe", "sub": "https://example.org", "aud": "monitor", "jti": "one", + "iat": int(NOW.timestamp()) - 30, "exp": int(NOW.timestamp()) + 60, + "purpose": "pqc-observation", "observation": {"group": "X25519MLKEM768"}} + + def sign(self, claims=None, header=None): + return jwt.encode(claims or self.claims, self.key, algorithm="EdDSA", + headers=header or {"kid": "probe-1", "typ": TOKEN_TYPE}) + + def verify(self, token=None, **kwargs): + options = dict(now=NOW, expected_audience="monitor", replay_store=self.store) + options.update(kwargs) + return verify_envelope(token or self.sign(), self.trust, "https://example.org", **options) + + def test_valid_and_durable_replay_rejection(self): + self.assertTrue(self.verify()["verified"]) + self.store = ReplayStore(self.store.path) + self.assertEqual(self.verify()["reason"], "replay") + + def test_unknown_issuer_key_and_revocation(self): + for change, reason in [({"iss": "attacker"}, "untrusted-issuer"), ({"sub": "other"}, "scope-mismatch")]: + self.assertEqual(self.verify(self.sign(dict(self.claims, **change)))["reason"], reason) + self.policy["revokedKeys"] = ["probe-1"] + self.assertEqual(self.verify()["reason"], "untrusted-key") + + def test_local_authorization_required(self): + self.policy["subjects"] = ["https://other.example"] + self.assertEqual(self.verify()["reason"], "issuer-not-authorized") + + def test_freshness_and_integer_time(self): + now = int(NOW.timestamp()) + for changes in ({"exp": now}, {"iat": now + 1}, {"iat": now - 5000}, + {"exp": now + 4000}, {"nbf": now + 1}, {"iat": True}, {"exp": "9999999999"}): + with self.subTest(changes=changes): + self.assertFalse(self.verify(self.sign(dict(self.claims, **changes)))["verified"]) + + def test_wrong_purpose_audience_type_and_alg(self): + self.assertFalse(self.verify(self.sign(dict(self.claims, aud="other")))["verified"]) + self.assertFalse(self.verify(self.sign(dict(self.claims, purpose="other")))["verified"]) + for header in ({"kid": "probe-1", "typ": "JWT"}, + {"kid": "probe-1", "typ": TOKEN_TYPE, "jku": "https://attacker"}, + {"kid": "probe-1", "typ": TOKEN_TYPE, "crit": ["custom"]}): + self.assertFalse(self.verify(self.sign(header=header))["verified"]) + unsigned = jwt.encode(self.claims, "", algorithm="none", headers={"kid": "probe-1", "typ": TOKEN_TYPE}) + self.assertFalse(self.verify(unsigned)["verified"]) + + def test_tamper_duplicate_json_and_size(self): + token = self.sign().split(".") + token[1] = base64.urlsafe_b64encode(b'{"iss":"probe","iss":"attacker"}').rstrip(b"=").decode() + self.assertFalse(self.verify(".".join(token))["verified"]) + self.assertFalse(self.verify("a" * 1048577)["verified"]) + self.assertFalse(self.verify("malformed")["verified"]) + + def test_bad_signature_does_not_poison_replay(self): + token = self.sign().split(".") + token[2] = base64.urlsafe_b64encode(b"x" * 64).rstrip(b"=").decode() + self.assertFalse(self.verify(".".join(token))["verified"]) + self.assertTrue(self.verify()["verified"]) + + def test_snapshot_scope_binding(self): + token = self.sign(dict(self.claims, snapshotDigest="sha256:a", scope="tls-endpoint")) + self.assertFalse(self.verify(token, expected_snapshot="sha256:b")["verified"]) + self.assertTrue(self.verify(token, expected_snapshot="sha256:a", expected_claims={"scope": "tls-endpoint"})["verified"]) + + def test_replay_policy_mandatory(self): + self.assertFalse(self.verify(replay_store=None)["verified"]) + self.assertFalse(self.verify(expected_audience=None)["verified"]) + + def test_new_jti_cannot_roll_back_issuance(self): + self.assertTrue(self.verify()["verified"]) + token = self.sign(dict(self.claims, jti="older", iat=self.claims["iat"] - 1)) + self.assertEqual(self.verify(token)["reason"], "observation-rollback") + + def test_malformed_policy_and_empty_replay_state_rejected(self): + self.policy["subjects"] = "prefix-https://example.org-suffix" + self.assertEqual(self.verify()["reason"], "invalid-trust-policy") + self.policy["subjects"] = ["https://example.org"] + self.store.path.write_text("{}") + self.store.path.chmod(0o600) + self.assertFalse(self.verify()["verified"]) + + def test_replay_capacity_and_clock_rollback_fail_closed(self): + self.store.max_entries = 1 + self.assertTrue(self.verify()["verified"]) + self.assertFalse(self.verify(self.sign(dict(self.claims, jti="two")))["verified"]) + self.assertFalse(self.verify(self.sign(dict(self.claims, jti="three")), now=NOW-dt.timedelta(seconds=1))["verified"]) + + def test_expired_entries_are_pruned(self): + self.store.max_entries = 1 + self.assertTrue(self.verify()["verified"]) + later = NOW + dt.timedelta(seconds=100) + claims = dict(self.claims, jti="two", iat=int(later.timestamp()), exp=int(later.timestamp())+60) + self.assertTrue(self.verify(self.sign(claims), now=later)["verified"]) + + def test_corrupt_or_insecure_state_fails_closed(self): + self.store.path.write_text("not json") + self.store.path.chmod(0o600) + self.assertFalse(self.verify()["verified"]) + self.store.path.write_text("{}") + self.store.path.chmod(0o644) + self.assertFalse(self.verify()["verified"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..de45e21 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,58 @@ +"""Repository-level structural and contract tests.""" +import json +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path +from jsonschema import Draft202012Validator, FormatChecker +from tools.render_draft import render + +ROOT=Path(__file__).resolve().parents[1] +SCHEMA=ROOT/"schema/crypto-agility-v2.schema.json" +EXAMPLE=ROOT/"examples/crypto-agility-v2.json" +XML=ROOT/"draft/draft-acosta-crypto-agility-manifest-02.xml" +MD=ROOT/"draft/draft-acosta-crypto-agility-manifest-02.md" + +class ContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.schema=json.loads(SCHEMA.read_text()) + cls.example=json.loads(EXAMPLE.read_text()) + cls.validator=Draft202012Validator(cls.schema,format_checker=FormatChecker()) + def test_schema_is_valid_2020_12(self): + Draft202012Validator.check_schema(self.schema) + def test_example_validates(self): + self.assertEqual(list(self.validator.iter_errors(self.example)),[]) + def test_rejects_wrong_version(self): + d=dict(self.example,version=99) + self.assertTrue(list(self.validator.iter_errors(d))) + def test_rejects_unknown_top_level_fields(self): + d=dict(self.example,untrustedCommand="run this") + self.assertTrue(list(self.validator.iter_errors(d))) + def test_rejects_invalid_digest(self): + d=json.loads(json.dumps(self.example)) + d["evidence"]=[{"id":"test","mediaType":"application/json","digest":{"algorithm":"sha-256","value":"a"*63}}] + self.assertTrue(list(self.validator.iter_errors(d))) + def test_xml_well_formed_and_named(self): + root=ET.parse(XML).getroot() + self.assertEqual(root.attrib["docName"],"draft-acosta-crypto-agility-manifest-02") + self.assertEqual(root.attrib["version"],"3") + def test_draft_markdown_mentions_same_revision(self): + self.assertIn("draft-acosta-crypto-agility-manifest-02",MD.read_text()) + def test_normative_schema_matches_standalone(self): + root=ET.parse(XML).getroot() + self.assertEqual(json.loads(root.find("back/section[@anchor='schema']/sourcecode").text),self.schema) + def test_minimal_example_matches_xml_and_validates(self): + example=json.loads((ROOT/"examples/minimal-v2.json").read_text()) + self.assertEqual(list(self.validator.iter_errors(example)),[]) + self.assertEqual(json.loads(ET.parse(XML).getroot().find("back/section[@anchor='example']/sourcecode").text),example) + def test_markdown_is_generated_from_xml(self): + self.assertEqual(MD.read_text(),render()) + def test_normative_references_cited_and_resolved(self): + root=ET.parse(XML).getroot() + references={ref.attrib['anchor'] for ref in root.findall('back/references/reference')} + citations={ref.attrib['target'] for ref in root.findall('.//xref')} + self.assertEqual(references,citations) + def test_no_claim_of_compliance_in_example(self): + self.assertNotIn("compliant",self.example) +if __name__=="__main__": + unittest.main() diff --git a/tests/test_cross_runtime.py b/tests/test_cross_runtime.py new file mode 100644 index 0000000..16f2b10 --- /dev/null +++ b/tests/test_cross_runtime.py @@ -0,0 +1,276 @@ +"""Independent Node/Python wire interoperability, strict trust and resource gates. + +The Node fixture uses only Node crypto for signing; this proves JWS wire +interoperability, not independent truth of an issuer's TLS measurement. +""" +import base64 +import concurrent.futures +import copy +import datetime as dt +import json +import subprocess +import tempfile +import time +import unittest +from pathlib import Path + +import jwt +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from jsonschema import Draft202012Validator, FormatChecker +from tools.attestation import ReplayStore, verify_envelope +from tools.monitor import snapshot + +ROOT = Path(__file__).resolve().parents[1] +NOW = dt.datetime(2026, 10, 12, tzinfo=dt.timezone.utc) +ISSUER, SUBJECT, AUDIENCE = 'https://collector.example', 'https://example.org', 'crypto-agility-test' +SCHEMA = json.loads((ROOT / 'schema/crypto-agility-v2.schema.json').read_text()) +EXAMPLE = json.loads((ROOT / 'examples/crypto-agility-v2.json').read_text()) +HEADER = {'alg': 'EdDSA', 'kid': 'test-key', 'typ': 'pqc-observation+jwt'} + + +def node(fixture, request): + result = subprocess.run(['node', str(ROOT / 'tests/fixtures' / fixture)], + input=json.dumps(request), text=True, capture_output=True, + check=True, timeout=15, cwd=ROOT) + return json.loads(result.stdout) + + +class CrossRuntimeJwsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.claims = {'iss': ISSUER, 'sub': SUBJECT, 'aud': AUDIENCE, + 'iat': int(NOW.timestamp()) - 60, 'exp': int(NOW.timestamp()) + 600, + 'jti': 'independent-node-statement', 'purpose': 'pqc-observation', + 'observation': {'label': 'café • 測定', 'value': 'unknown'}} + cls.issued = node('node_jws_issuer.mjs', {'operation': 'sign', 'header': HEADER, 'claims': cls.claims}) + cls.trust = {'issuers': {ISSUER: {'keys': {'test-key': cls.issued['publicJwk']['x']}, + 'subjects': [SUBJECT], 'audiences': [AUDIENCE], 'purposes': ['pqc-observation'], + 'maxAgeSeconds': 3600, 'maxLifetimeSeconds': 3600}}} + + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.store = ReplayStore(Path(self.directory.name) / 'replay.json') + + def issue(self, claims=None, header=None, payload_text=None): + request = {'operation': 'sign', 'privateJwk': self.issued['privateJwk'], + 'header': header or HEADER, 'claims': claims or self.claims} + if payload_text is not None: + request['payloadText'] = payload_text + return node('node_jws_issuer.mjs', request)['token'] + + def verify(self, token, **options): + return verify_envelope(token, options.pop('trust', self.trust), + options.pop('subject', SUBJECT), now=options.pop('now', NOW), + expected_audience=AUDIENCE, replay_store=self.store, **options) + + def test_node_pretty_unicode_jws_verified_python_without_reserialization(self): + result = self.verify(self.issued['token']) + self.assertTrue(result['verified'], result) + self.assertEqual(result['claims'], self.claims) + self.assertIn('not independent', result['limitation']) + + def test_python_jws_crypto_verified_by_independent_node(self): + raw = base64.urlsafe_b64decode(self.issued['privateJwk']['d'] + '==') + key = Ed25519PrivateKey.from_private_bytes(raw) + token = jwt.encode(self.claims, key, algorithm='EdDSA', headers=HEADER) + self.assertTrue(node('node_jws_issuer.mjs', {'operation': 'verify', 'token': token, + 'publicJwk': self.issued['publicJwk']})['verified']) + parts = token.split('.') + replacement = dict(self.claims, sub='https://attacker.example') + parts[1] = base64.urlsafe_b64encode(json.dumps(replacement).encode()).rstrip(b'=').decode() + self.assertFalse(node('node_jws_issuer.mjs', {'operation': 'verify', 'token': '.'.join(parts), + 'publicJwk': self.issued['publicJwk']})['verified']) + + def test_independently_signed_adversarial_claims_rejected(self): + cases = { + 'expired': {'exp': int(NOW.timestamp())}, + 'future': {'iat': int(NOW.timestamp()) + 1}, + 'wrong-subject': {'sub': 'https://other.example'}, + 'wrong-audience': {'aud': 'different-service'}, + 'untrusted-issuer': {'iss': 'https://attacker.example'}, + 'wrong-purpose': {'purpose': 'deployment-authorization'}, + 'boolean-time': {'iat': True}, + 'overlong-lifetime': {'exp': int(NOW.timestamp()) + 7200}, + } + for name, changes in cases.items(): + with self.subTest(name=name): + result = self.verify(self.issue(dict(self.claims, **changes))) + self.assertFalse(result['verified'], result) + # Rejections must not let an attacker consume an otherwise valid token. + self.assertTrue(self.verify(self.issued['token'])['verified']) + + def test_signed_duplicate_claim_and_remote_key_headers_rejected(self): + duplicate = json.dumps(self.claims)[:-1] + ', "sub": "https://example.org"}' + self.assertFalse(self.verify(self.issue(payload_text=duplicate))['verified']) + for addition in ({'jku': 'https://attacker.example/jwks'}, {'crit': ['b64']}, {'alg': 'HS256'}): + with self.subTest(addition=addition): + self.assertFalse(self.verify(self.issue(header={**HEADER, **addition}))['verified']) + + def test_atomic_replay_protection_across_concurrent_verifiers_and_restart(self): + path = Path(self.directory.name) / 'replay.json' + def accept(_): + return verify_envelope(self.issued['token'], self.trust, SUBJECT, now=NOW, + expected_audience=AUDIENCE, replay_store=ReplayStore(path)) + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(accept, range(8))) + self.assertEqual(sum(result['verified'] for result in results), 1) + self.assertEqual({result['reason'] for result in results if not result['verified']}, {'replay'}) + self.assertEqual(accept(None)['reason'], 'replay') + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_new_jti_cannot_replace_newer_statement_with_older_issuance(self): + self.assertTrue(self.verify(self.issued['token'])['verified']) + earlier = dict(self.claims, jti='fresh-identifier-old-observation', iat=self.claims['iat'] - 1) + self.assertEqual(self.verify(self.issue(earlier))['reason'], 'observation-rollback') + + def test_key_revocation_applies_without_network_or_cached_trust(self): + trust = copy.deepcopy(self.trust) + trust['issuers'][ISSUER]['revokedKeys'] = ['test-key'] + self.assertFalse(self.verify(self.issued['token'], trust=trust)['verified']) + + +class IndependentManifestConsumerTests(unittest.TestCase): + def test_node_ajv_and_python_jsonschema_agree_on_structural_corpus(self): + corpus = [{'id': 'example', 'manifest': copy.deepcopy(EXAMPLE), 'expected': True}] + def case(name, change): + manifest = copy.deepcopy(EXAMPLE) + change(manifest) + corpus.append({'id': name, 'manifest': manifest, 'expected': False}) + case('unknown-version', lambda x: x.update(version=3)) + case('unknown-field', lambda x: x.update(execute='rm')) + case('non-utc-timestamp', lambda x: x.update(generatedAt='2026-10-11T10:00:00+01:00')) + case('impossible-calendar-date', lambda x: x.update(generatedAt='2026-02-30T10:00:00Z')) + case('too-many-profiles', lambda x: x.update(profiles=x['profiles'] * 33)) + case('empty-profiles', lambda x: x.update(profiles=[])) + case('independent-verification-self-claim', lambda x: x['observations'][0].update(method='verified')) + case('invalid-device-class', lambda x: x['profiles'][0].update(deviceClass='universal')) + case('duplicate-algorithm', lambda x: x['profiles'][0].update(keyEstablishment=['X25519', 'X25519'])) + case('missing-evidence-subject', lambda x: x.update(evidence=[{ + 'id': 'e', 'mediaType': 'application/json', 'digest': {'algorithm': 'sha-256', 'value': 'a' * 64}}])) + case('wrong-digest-length', lambda x: x.update(evidence=[{ + 'id': 'e', 'mediaType': 'application/json', 'subjectId': SUBJECT, + 'digest': {'algorithm': 'sha-384', 'value': 'a' * 64}}])) + validator = Draft202012Validator(SCHEMA, format_checker=FormatChecker()) + expected = [{'id': item['id'], 'valid': item['expected']} for item in corpus] + python_results = [{'id': item['id'], 'valid': validator.is_valid(item['manifest'])} for item in corpus] + node_results = node('node_manifest_consumer.mjs', {'schema': SCHEMA, 'cases': corpus}) + self.assertEqual(python_results, expected) + self.assertEqual(node_results, expected) + + def test_maximum_profile_matrix_has_bounded_work_and_no_verified_claims(self): + manifest = copy.deepcopy(EXAMPLE) + template = manifest['profiles'][0] + manifest['profiles'] = [{**template, 'id': f'profile-{i}', + 'endpointRole': 'client' if i < 32 else 'server'} for i in range(64)] + # Remove now-dangling references, so this measures the supported matrix. + manifest['migration'] = [] + start = time.monotonic() + report = snapshot(manifest, SCHEMA, NOW) + elapsed = time.monotonic() - start + self.assertNotEqual(report['assessment']['status'], 'invalid') + self.assertEqual(len(report['pairs']), 32 * 32) + self.assertFalse(any(x['assessment']['status'] == 'verified' for x in report['pairs'])) + self.assertLess(elapsed, 5.0, f'64 profile assessment took {elapsed:.3f}s') + + +class LiveMeasurementPipelineTests(unittest.TestCase): + """A real PKIX-validated loopback connection through independent JWS to state.""" + def test_live_tls_node_issuer_python_ingestion_monitor_expiry(self): + import test_tls_live + from tools.observatory_ingestion import ingest_measurement + from tools.state import update_state + + fixture = test_tls_live.LiveTLSTests + fixture.setUpClass() + self.addCleanup(fixture.tearDownClass) + result = fixture().run_connection('X25519', ['X25519']) + self.assertTrue(result['certificateValidated'], result) + self.assertFalse(result['hybridKexVerified']) + subject = 'https://localhost:' + str(result['port']) + now = dt.datetime.now(dt.timezone.utc) + claims = {'iss': ISSUER, 'sub': subject, 'aud': AUDIENCE, + 'iat': int(now.timestamp()), 'exp': int(now.timestamp()) + 60, + 'jti': 'live-loopback-connection', 'purpose': 'pqc-observation', + 'observation': result['evidence']} + issued = node('node_jws_issuer.mjs', {'operation': 'sign', 'header': HEADER, 'claims': claims}) + trust = {'issuers': {ISSUER: {'keys': {'test-key': issued['publicJwk']['x']}, + 'subjects': [subject], 'audiences': [AUDIENCE], 'purposes': ['pqc-observation']}}} + manifest = copy.deepcopy(EXAMPLE) + manifest.update(generatedAt=now.strftime('%Y-%m-%dT%H:%M:%SZ'), + expiresAt=(now + dt.timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ'), + observations=[], migration=[]) + manifest['subject']['id'] = subject + with tempfile.TemporaryDirectory() as directory: + store = ReplayStore(Path(directory) / 'replay.json') + ingested = ingest_measurement(issued['token'], result['transcript'].encode(), trust, subject, + expected_audience=AUDIENCE, replay_store=store, now=now) + self.assertTrue(ingested['accepted'], ingested) + self.assertFalse(ingested['independentlyVerified']) + first = snapshot(manifest, SCHEMA, now, authenticated_observations=[ingested]) + self.assertEqual(first['measurements'][0]['status'], 'authenticated-observation') + self.assertFalse(first['measurements'][0]['independentlyVerified']) + state = Path(directory) / 'monitor.json' + update_state(state, first) + later = now + dt.timedelta(seconds=61) + stale = snapshot(manifest, SCHEMA, later, authenticated_observations=[ingested]) + delta = update_state(state, stale) + self.assertEqual(stale['measurements'][0]['status'], 'stale') + self.assertTrue(any(alert['type'] == 'verification-regression' + and alert['reason'] == 'measurement-expired' for alert in delta['alerts']), delta) + self.assertTrue(all(alert['action'] and alert['id'] for alert in delta['alerts'])) + retained = state.read_bytes() + replay = ingest_measurement(issued['token'], result['transcript'].encode(), trust, subject, + expected_audience=AUDIENCE, replay_store=store, now=now) + self.assertFalse(replay['accepted']) + self.assertEqual(state.read_bytes(), retained) + + # Full monitor entry point: real discovery parsing/cache, exact snapshot + # binding and evidence reuse. Only the HTTP transport is substituted. + import hashlib + from unittest.mock import patch + from tools.discovery import DiscoveryClient + from tools.monitor import run_once + raw = json.dumps(manifest, indent=2).encode() + bound_claims = dict(claims, jti='snapshot-bound-live-connection', + snapshotDigest='sha256:' + hashlib.sha256(raw).hexdigest()) + bound = node('node_jws_issuer.mjs', {'operation': 'sign', 'header': HEADER, + 'claims': bound_claims, 'privateJwk': issued['privateJwk']}) + evidence = {'audience': AUDIENCE, 'replay_store': store} + for name, data in [('token', bound['token'].encode()), + ('transcript', result['transcript'].encode()), + ('trust', json.dumps(trust).encode())]: + evidence[name] = Path(directory) / name + evidence[name].write_bytes(data) + remote_state = Path(directory) / 'remote-monitor.json' + client = DiscoveryClient(['https://manifest.example']) + cache = {} + def cycle(instant): + return run_once(None, SCHEMA, remote_state, instant, discovery=client, + origin='https://manifest.example', expected_subject=subject, + evidence=evidence, measurement_cache=cache) + with patch('tools.discovery._request') as transport: + transport.return_value = (200, {'content-type': 'application/json', + 'etag': '"snapshot-1"', 'cache-control': 'max-age=300'}, raw) + initial = cycle(now) + self.assertEqual(initial['measurements'][0]['status'], 'authenticated-observation', initial) + cached = cycle(now + dt.timedelta(seconds=1)) + self.assertEqual(cached['measurements'][0]['status'], 'authenticated-observation', cached) + self.assertEqual(transport.call_count, 1) + expired_evidence = cycle(now + dt.timedelta(seconds=61)) + self.assertEqual(expired_evidence['measurements'][0]['status'], 'stale') + self.assertIn('measurement-expired', {x['code'] for x in expired_evidence['assessment']['findings']}) + original = remote_state.read_bytes() + transport.return_value = (304, {}, b'') + expired_manifest = cycle(now + dt.timedelta(hours=2)) + self.assertEqual(expired_manifest['assessment']['status'], 'invalid') + retained_baseline = json.loads(original) + after_failure = json.loads(remote_state.read_bytes()) + self.assertEqual({k: v for k, v in after_failure.items() if k != 'pendingAlerts'}, + {k: v for k, v in retained_baseline.items() if k != 'pendingAlerts'}) + self.assertTrue(any(a['type'] == 'monitor-failure' for a in after_failure['pendingAlerts'])) + self.assertEqual(expired_manifest['changes']['alerts'][0]['type'], 'monitor-failure') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..0ab844a --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,177 @@ +import copy +import datetime as dt +import json +from pathlib import Path +import unittest +from unittest.mock import patch +from tools.discovery import DiscoveryClient, _public_addresses, normalize_origin, strict_json + +NOW = dt.datetime(2026, 10, 12, tzinfo=dt.timezone.utc) +ORIGIN = 'https://example.com' +BASE = json.loads((Path(__file__).resolve().parents[1] / 'examples/crypto-agility-v2.json').read_text()) + + +class DiscoveryTests(unittest.TestCase): + def setUp(self): + self.manifest = copy.deepcopy(BASE) + self.manifest.update(generatedAt='2026-10-11T00:00:00Z', expiresAt='2026-10-13T00:00:00Z') + self.subject = self.manifest['subject']['id'] + self.client = DiscoveryClient([ORIGIN]) + self.headers = {'content-type': 'application/json', 'etag': '"one"', 'cache-control': 'max-age=60'} + + def response(self, **headers): + return 200, {**self.headers, **headers}, json.dumps(self.manifest).encode() + + @patch('tools.discovery._request') + def test_cache_is_bounded_and_defensive(self, request): + request.return_value = self.response() + first = self.client.fetch(ORIGIN, self.subject, now=NOW) + first['manifest']['subject']['id'] = 'mutated' + self.assertEqual(self.client.fetch(ORIGIN, self.subject, now=NOW)['source'], 'cache') + request.assert_called_once() + + @patch('tools.discovery._request') + def test_conditional_get_and_304_preserves_expiry(self, request): + request.return_value = self.response() + self.client.fetch(ORIGIN, self.subject, now=NOW) + request.return_value = 304, {}, b'' + result = self.client.fetch(ORIGIN, self.subject, now=NOW + dt.timedelta(seconds=61)) + self.assertEqual(result['source'], 'revalidated') + self.assertEqual(request.call_args.args[1]['If-None-Match'], '"one"') + self.assertEqual(result['manifest']['expiresAt'], self.manifest['expiresAt']) + with self.assertRaisesRegex(ValueError, 'expired'): + self.client.fetch(ORIGIN, self.subject, now=NOW + dt.timedelta(days=2)) + + @patch('tools.discovery._request') + def test_last_modified_and_age(self, request): + headers = {'content-type': 'application/json', 'last-modified': 'Sun, 11 Oct 2026 00:00:00 GMT', 'cache-control': 'max-age=60', 'age': '60'} + request.return_value = 200, headers, json.dumps(self.manifest).encode() + self.client.fetch(ORIGIN, self.subject, now=NOW) + request.return_value = 304, {}, b'' + self.client.fetch(ORIGIN, self.subject, now=NOW) + self.assertIn('If-Modified-Since', request.call_args.args[1]) + + @patch('tools.discovery._request') + def test_no_store(self, request): + request.return_value = self.response(**{'cache-control': 'no-store,max-age=600'}) + self.client.fetch(ORIGIN, self.subject, now=NOW) + self.client.fetch(ORIGIN, self.subject, now=NOW) + self.assertNotIn('If-None-Match', request.call_args.args[1]) + self.assertEqual(len(self.client._cache), 0) + + @patch('tools.discovery._request') + def test_change_hint_forces_configured_origin_only(self, request): + request.return_value = self.response() + self.client.fetch(ORIGIN, self.subject, now=NOW) + request.return_value = 304, {}, b'' + self.assertEqual(self.client.fetch(ORIGIN, self.subject, now=NOW, force=True)['source'], 'revalidated') + self.assertEqual(request.call_args.args[0], 'example.com') + + @patch('tools.discovery._request') + def test_redirect_error_and_unsolicited_304_fail_closed(self, request): + for status in [301, 302, 307, 308, 304, 404, 500]: + request.return_value = status, {'location': 'https://127.0.0.1'}, b'' + with self.subTest(status=status), self.assertRaises(ValueError): + self.client.fetch(ORIGIN, self.subject, now=NOW) + + @patch('tools.discovery._request') + def test_wrong_subject_media_type_and_unsafe_etag(self, request): + request.return_value = self.response() + with self.assertRaisesRegex(ValueError, 'subject'): + self.client.fetch(ORIGIN, 'different', now=NOW) + for headers in [{'content-type': 'text/html'}, {'etag': 'a\r\nX-injected: b'}]: + request.return_value = self.response(**headers) + with self.assertRaises(ValueError): + self.client.fetch(ORIGIN, self.subject, now=NOW) + + @patch('tools.discovery._request') + def test_size_invalid_json_and_time_limits(self, request): + for body in [b'x' * 1048577, b'{"version":2,"version":2}', b'[' * 40 + b']' * 40, b'{"n":NaN}']: + request.return_value = 200, self.headers, body + with self.assertRaises(ValueError): + self.client.fetch(ORIGIN, self.subject, now=NOW) + with self.assertRaises(ValueError): + self.client.fetch(ORIGIN, self.subject, now=NOW.replace(tzinfo=None)) + + def test_bad_origins_and_allowlist(self): + for origin in ['http://example.com', 'https://user@example.com', 'https://example.com/x', 'https://example.com:22', 'https://example.com?x', 'https://example.com\n', 'https://[::1]']: + with self.subTest(origin=origin), self.assertRaises(ValueError): + normalize_origin(origin) + with self.assertRaisesRegex(ValueError, 'allowed'): + self.client.fetch('https://other.com', self.subject, now=NOW) + + @patch('tools.discovery.socket.getaddrinfo') + def test_private_mixed_and_transition_dns_rejected(self, dns): + for ip in ['224.0.0.1', 'ff02::1', '127.0.0.1', '10.0.0.1', '169.254.169.254', '::1', '::ffff:8.8.8.8', '2002:0808:0808::1']: + dns.return_value = [(2, 1, 6, '', ('8.8.8.8', 443)), (2, 1, 6, '', (ip, 443))] + with self.subTest(ip=ip), self.assertRaises(ValueError): + _public_addresses('example.com') + dns.return_value = [(2, 1, 6, '', ('8.8.8.8', 443))] + self.assertEqual(_public_addresses('example.com'), ['8.8.8.8']) + + def test_json_nesting_and_duplicates(self): + for raw in [b'[' * 1500 + b']' * 1500, b'{"x":1,"x":2}', b'{"x":Infinity}']: + with self.assertRaises(ValueError): + strict_json(raw) + + +def slow_worker(*args): + import time + time.sleep(10) + + +class TransportTests(unittest.TestCase): + def test_total_deadline_terminates_blocked_worker(self): + import multiprocessing + import time + from tools.discovery import _request + real_context = multiprocessing.get_context('spawn') + class Context: + Pipe = staticmethod(real_context.Pipe) + @staticmethod + def Process(**kwargs): + kwargs['target'] = slow_worker + return real_context.Process(**kwargs) + start = time.monotonic() + with patch('tools.discovery.multiprocessing.get_context', return_value=Context()): + with self.assertRaises(TimeoutError): + _request('example.com', {}, 0.05, 1024) + self.assertLess(time.monotonic() - start, 3) + + @patch('tools.discovery.socket.create_connection') + @patch('tools.discovery.ssl.create_default_context') + def test_connect_uses_vetted_ip_but_validates_hostname(self, context, connect): + from tools.discovery import _PinnedHTTPS + conn = _PinnedHTTPS('example.com', '8.8.8.8', 5) + conn.connect() + connect.assert_called_once_with(('8.8.8.8', 443), 5) + context.return_value.wrap_socket.assert_called_once_with(connect.return_value, server_hostname='example.com') + + @patch('tools.discovery._PinnedHTTPS') + @patch('tools.discovery._public_addresses', return_value=['8.8.8.8']) + def test_transport_limits_and_closes(self, addresses, connection): + from tools.discovery import _request_in_worker + response = connection.return_value.getresponse.return_value + response.status = 200 + cases = [([('Content-Length', '5000')], [b'']), + ([('Content-Encoding', 'gzip')], [b'']), + ([('Content-Type', 'application/json'), ('Content-Type', 'text/html')], [b'']), + ([], [b'x' * 1025]), + ([('Content-Length', '5')], [b'x', b''])] + for headers, chunks in cases: + response.getheaders.return_value = headers + response.read1.side_effect = chunks + with self.subTest(headers=headers), self.assertRaises(ValueError): + _request_in_worker('example.com', {}, 5, 1024) + self.assertTrue(connection.return_value.close.called) + + +class MonitoringDiscoveryTests(unittest.TestCase): + @patch('tools.discovery._request', return_value=(200, {'content-type': 'application/json'}, b'{"version":2}')) + def test_remote_schema_failure_emits_monitor_alert(self, request): + from tools.discovery import SCHEMA + from tools.monitor import run_once + report = run_once(None, SCHEMA, now=NOW, discovery=DiscoveryClient([ORIGIN]), + origin=ORIGIN, expected_subject=ORIGIN) + self.assertEqual(report['assessment']['status'], 'invalid') + self.assertEqual(report['changes']['alerts'][0]['type'], 'monitor-failure') diff --git a/tests/test_discovery_live.py b/tests/test_discovery_live.py new file mode 100644 index 0000000..4dc6ef5 --- /dev/null +++ b/tests/test_discovery_live.py @@ -0,0 +1,98 @@ +"""Real HTTPS transport on isolated loopback; production SSRF policy unchanged.""" +import datetime as dt +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import socket +import ssl +import tempfile +import threading +import unittest +from unittest.mock import patch +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from tools.discovery import DiscoveryClient, _PinnedHTTPS, _request_in_worker + + +class HTTPSDiscoveryTests(unittest.TestCase): + def test_real_https_200_conditional_304_and_unknown_ca(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')]) + now = dt.datetime.now(dt.timezone.utc) + certificate = (x509.CertificateBuilder().subject_name(name).issuer_name(name) + .public_key(key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(days=1)).not_valid_after(now + dt.timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName('localhost')]), False) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), True).sign(key, hashes.SHA256())) + cert_path, key_path = root / 'ca.pem', root / 'key.pem' + cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + manifest = json.loads((Path(__file__).resolve().parents[1] / 'examples/crypto-agility-v2.json').read_text()) + manifest['subject']['id'] = 'https://localhost' + manifest['generatedAt'] = (now - dt.timedelta(minutes=1)).strftime('%Y-%m-%dT%H:%M:%SZ') + manifest['expiresAt'] = (now + dt.timedelta(minutes=5)).strftime('%Y-%m-%dT%H:%M:%SZ') + body = json.dumps(manifest).encode() + requests = [] + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests.append((self.path, self.headers.get('If-None-Match'))) + if self.headers.get('If-None-Match') == '"one"': + self.send_response(304) + self.send_header('ETag', '"one"') + self.end_headers() + else: + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('ETag', '"one"') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'max-age=0') + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain(cert_path, key_path) + server.socket = server_context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + trusted = ssl.create_default_context(cafile=str(cert_path)) + port = server.server_address[1] + class LocalPinned(_PinnedHTTPS): + trust_context = trusted + def __init__(self, host, address, timeout): + super().__init__(host, address, timeout) + self._context = self.trust_context + def connect(self): + raw = socket.create_connection((self.address, port), self.timeout) + try: + self.sock = self._context.wrap_socket(raw, server_hostname=self.host) + except BaseException: + raw.close() + raise + try: + # These two patches are exclusively the test's loopback transport + # injection. Production never permits loopback or arbitrary ports. + with patch('tools.discovery._public_addresses', return_value=['127.0.0.1']), patch('tools.discovery._PinnedHTTPS', LocalPinned): + # Bypass process isolation only to retain the test injections; + # the unchanged request implementation does actual HTTPS I/O. + with patch('tools.discovery._request', side_effect=_request_in_worker): + client = DiscoveryClient(['https://localhost']) + first = client.fetch('https://localhost', 'https://localhost', now=now) + second = client.fetch('https://localhost', 'https://localhost', now=now) + self.assertEqual(first['source'], 'http') + self.assertEqual(second['source'], 'revalidated') + self.assertEqual(first['body'], body) + self.assertEqual(second['body'], body) + LocalPinned.trust_context = ssl.create_default_context() + with self.assertRaises(ssl.SSLCertVerificationError): + _request_in_worker('localhost', {}, 3, 1048576) + self.assertEqual(requests, [('/.well-known/crypto-agility.json', None), ('/.well-known/crypto-agility.json', '"one"')]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) diff --git a/tests/test_evidence_compatibility.py b/tests/test_evidence_compatibility.py new file mode 100644 index 0000000..e0e649d --- /dev/null +++ b/tests/test_evidence_compatibility.py @@ -0,0 +1,28 @@ +import hashlib, json, tempfile, unittest +from pathlib import Path +from tools.evidence import verify_local_evidence +from tools.compatibility import assess_pair + +class Tests(unittest.TestCase): + def test_digest_verified(self): + with tempfile.TemporaryDirectory() as d: + Path(d,"a.txt").write_bytes(b"example") + e={"id":"a","uri":"file:a.txt","digest":{"algorithm":"sha-256","value":hashlib.sha256(b"example").hexdigest()}} + self.assertEqual(verify_local_evidence({"evidence":[e]},d)[0]["status"],"digest-valid") + def test_digest_mismatch(self): + with tempfile.TemporaryDirectory() as d: + Path(d,"a.txt").write_bytes(b"wrong") + e={"id":"a","uri":"file:a.txt","digest":{"algorithm":"sha-256","value":hashlib.sha256(b"expected").hexdigest()}} + self.assertEqual(verify_local_evidence({"evidence":[e]},d)[0]["status"],"digest-mismatch") + def test_traversal_rejected(self): + with tempfile.TemporaryDirectory() as d: + e={"id":"a","uri":"file:../secret","digest":{"algorithm":"sha-256","value":"a"*64}} + self.assertEqual(verify_local_evidence({"evidence":[e]},d)[0]["status"],"rejected") + def test_compatibility_is_not_proof(self): + c={"endpointRole":"client","protocol":"TLS 1.3","keyEstablishment":["X25519MLKEM768"],"authentication":["ECDSA"]} + s={"endpointRole":"server","protocol":"TLS 1.3","keyEstablishment":["X25519MLKEM768"],"authentication":["ECDSA"]} + self.assertEqual(assess_pair(c,s)["status"],"declared-compatible") + def test_mismatch(self): + c={"endpointRole":"client","protocol":"TLS 1.3","keyEstablishment":["X25519"],"authentication":["ECDSA"]} + s={"endpointRole":"server","protocol":"TLS 1.3","keyEstablishment":["X25519MLKEM768"],"authentication":["ECDSA"]} + self.assertEqual(assess_pair(c,s)["status"],"incompatible") diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..f375173 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,102 @@ +"""Offline integration tests spanning schema, assessment, monitoring and state.""" +import copy +import datetime as dt +import json +import tempfile +import unittest +from pathlib import Path +from tools.monitor import snapshot +from tools.state import update_state + +ROOT=Path(__file__).resolve().parents[1] +SCHEMA=json.loads((ROOT/"schema/crypto-agility-v2.schema.json").read_text()) +EXAMPLE=json.loads((ROOT/"examples/crypto-agility-v2.json").read_text()) +NOW=dt.datetime(2026,10,12,tzinfo=dt.timezone.utc) + +class IntegrationTests(unittest.TestCase): + def test_snapshot_to_state_and_regression(self): + m=copy.deepcopy(EXAMPLE) + m["profiles"].append({"id":"server","endpointRole":"server","deviceClass":"server","protocol":"TLS 1.3","keyEstablishment":["X25519MLKEM768"],"authentication":["ECDSA"],"fallback":"deny-classical"}) + first=snapshot(m,SCHEMA,NOW) + self.assertEqual(first["assessment"]["status"],"needs-review") + pair=[p for p in first["pairs"] if p["client"]=="modern-browser" and p["server"]=="server"] + self.assertEqual(pair[0]["assessment"]["status"],"declared-compatible") + with tempfile.TemporaryDirectory() as d: + state=Path(d)/"state.json" + self.assertTrue(update_state(state,first)["initialBaseline"]) + m["profiles"][-1]["keyEstablishment"]=["X448"] + second=snapshot(m,SCHEMA,NOW) + delta=update_state(state,second) + self.assertIn("compatibility-regression",{a["type"] for a in delta["alerts"]}) + def test_invalid_manifest_does_not_generate_pairs(self): + m=copy.deepcopy(EXAMPLE);m["profiles"]="not an array" + result=snapshot(m,SCHEMA,NOW) + self.assertEqual(result["assessment"]["status"],"invalid") + self.assertEqual(result["pairs"],[]) + def test_gateway_hops_are_not_end_to_end_proof(self): + m=copy.deepcopy(EXAMPLE) + m["profiles"].append({"id":"gateway","endpointRole":"gateway","deviceClass":"network","protocol":"TLS 1.3","keyEstablishment":["X25519MLKEM768"],"authentication":["ECDSA"],"fallback":"deny-classical"}) + report=snapshot(m,SCHEMA,NOW) + self.assertTrue(any(p["server"]=="gateway" for p in report["pairs"])) + self.assertFalse(any(p["assessment"]["status"]=="verified" for p in report["pairs"])) + def test_expiry_propagates_to_monitor(self): + result=snapshot(EXAMPLE,SCHEMA,dt.datetime(2026,11,1,tzinfo=dt.timezone.utc)) + self.assertIn("expired",{x["code"] for x in result["assessment"]["findings"]}) + +class AlertDeliveryIntegrationTests(unittest.TestCase): + def manifest_text(self): + manifest=copy.deepcopy(EXAMPLE) + now=dt.datetime.now(dt.timezone.utc) + manifest["generatedAt"]=(now-dt.timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + manifest["expiresAt"]=(now+dt.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + return json.dumps(manifest) + def command(self, *args): + import subprocess,sys + return subprocess.run([sys.executable,'-m','tools.monitor',*map(str,args)],cwd=ROOT,text=True,capture_output=True) + def test_failed_output_retries_pending_alert_and_cli_acknowledges_delivery(self): + with tempfile.TemporaryDirectory() as d: + directory=Path(d);state=directory/'state.json';manifest=directory/'manifest.json' + manifest.write_text(self.manifest_text()) + failed=self.command(manifest,'--state',state,'--output',directory/'missing'/'out.json') + self.assertNotEqual(failed.returncode,0) + pending=json.loads(state.read_text())['pendingAlerts'] + self.assertTrue(pending) + retry=self.command(manifest,'--state',state) + report=json.loads(retry.stdout) + self.assertEqual(report['changes']['alerts'],pending) + ack_args=[arg for alert in pending for arg in ('--ack-alert',alert['deliveryId'])] + ack=self.command('--state',state,*ack_args) + self.assertEqual(ack.returncode,0,ack.stderr) + self.assertEqual(json.loads(ack.stdout)['acknowledged'],len(pending)) + again=json.loads(self.command(manifest,'--state',state).stdout) + self.assertEqual(again['changes']['alerts'],[]) + + def test_invalid_input_alert_survives_output_failure_and_repair(self): + with tempfile.TemporaryDirectory() as d: + directory=Path(d);state=directory/'state.json';manifest=directory/'manifest.json' + manifest.write_text(self.manifest_text()) + self.command(manifest,'--state',state) + before=json.loads(state.read_text()) + manifest.write_text('{broken') + self.command(manifest,'--state',state,'--output',directory/'missing'/'out.json') + after=json.loads(state.read_text()) + self.assertEqual(after['subject'],before['subject']) + self.assertEqual(after['assessment'],before['assessment']) + manifest.write_text(self.manifest_text()) + repaired=json.loads(self.command(manifest,'--state',state).stdout) + self.assertIn('monitor-failure',{a['type'] for a in repaired['changes']['alerts']}) + def test_invalid_first_input_retains_alert_without_establishing_baseline(self): + with tempfile.TemporaryDirectory() as d: + directory=Path(d);state=directory/'state.json';manifest=directory/'manifest.json' + manifest.write_text('{broken') + invalid=json.loads(self.command(manifest,'--state',state).stdout) + self.assertEqual(invalid['assessment']['status'],'invalid') + saved=json.loads(state.read_text()) + self.assertNotIn('subject',saved) + manifest.write_text(self.manifest_text()) + repaired=json.loads(self.command(manifest,'--state',state).stdout) + self.assertTrue(repaired['changes']['initialBaseline']) + self.assertIn('monitor-failure',{a['type'] for a in repaired['changes']['alerts']}) + +if __name__=="__main__": + unittest.main() diff --git a/tests/test_mldsa.py b/tests/test_mldsa.py new file mode 100644 index 0000000..e785cae --- /dev/null +++ b/tests/test_mldsa.py @@ -0,0 +1,100 @@ +"""RFC9964 JWS interop: actual Node ML-DSA signer -> OpenSSL/PyJWT verifier.""" +import base64 +import copy +import datetime as dt +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from tools.attestation import verify_envelope, ReplayStore, TOKEN_TYPE + +ROOT = Path(__file__).resolve().parents[1] +NOW = dt.datetime(2026, 10, 12, tzinfo=dt.timezone.utc) + + +class MLDSATests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which('node'): + raise RuntimeError('Node 24.6+ is required for ML-DSA interoperability') + cls.claims = {'iss': 'authority', 'sub': 'https://example.org', 'aud': 'monitor', + 'jti': 'mldsa-test', 'iat': int(NOW.timestamp())-30, + 'exp': int(NOW.timestamp())+60, 'purpose': 'pqc-observation', + 'observation': {'group': 'X25519MLKEM768'}, 'snapshotDigest': 'sha256:test'} + cls.header = {'alg': 'ML-DSA-65', 'kid': 'kms-key', 'typ': TOKEN_TYPE} + cls.fixture = cls.sign() + + @classmethod + def sign(cls, **kwargs): + request = dict(claims=cls.claims, header=cls.header, **kwargs) + completed = subprocess.run(['node', str(ROOT/'tests/mldsa_sign.mjs')], + input=json.dumps(request), text=True, capture_output=True, + timeout=15, check=True) + return json.loads(completed.stdout) + + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.store = ReplayStore(Path(self.directory.name)/'state.json') + self.policy = {'keys': {'kms-key': {'alg': 'ML-DSA-65', 'format': 'spki-der', + 'publicKey': self.fixture['publicKey']}}, + 'subjects': ['https://example.org'], 'audiences': ['monitor'], + 'purposes': ['pqc-observation']} + self.trust = {'issuers': {'authority': self.policy}} + + def verify(self, token=None): + return verify_envelope(token or self.fixture['token'], self.trust, 'https://example.org', + now=NOW, expected_audience='monitor', replay_store=self.store, + expected_snapshot='sha256:test') + + def test_node_signature_verifies_with_openssl_and_replay_fails(self): + result = self.verify() + self.assertTrue(result['verified'], result) + self.assertEqual(result['signingAlgorithm'], 'ML-DSA-65') + self.assertEqual(self.verify()['reason'], 'replay') + + def test_tampered_signature_cannot_poison_replay(self): + parts = self.fixture['token'].split('.') + signature = bytearray(base64.urlsafe_b64decode(parts[2]+'='*(-len(parts[2])%4))) + signature[42] ^= 1 + parts[2] = base64.urlsafe_b64encode(signature).rstrip(b'=').decode() + self.assertFalse(self.verify('.'.join(parts))['verified']) + self.assertTrue(self.verify()['verified']) + + def test_context_and_prehashed_messages_are_not_rfc9964(self): + for mode in ('context', 'prehashed'): + fixture = self.sign(mode=mode) + self.policy['keys']['kms-key']['publicKey'] = fixture['publicKey'] + self.assertFalse(self.verify(fixture['token'])['verified']) + + def test_trust_algorithm_cannot_be_selected_by_token(self): + self.policy['keys']['kms-key']['alg'] = 'EdDSA' + self.assertEqual(self.verify()['reason'], 'key-algorithm-mismatch') + self.policy['keys']['kms-key'] = base64.urlsafe_b64encode(b'x'*32).rstrip(b'=').decode() + self.assertEqual(self.verify()['reason'], 'key-algorithm-mismatch') + + def test_wrong_spki_oid_trailing_bytes_and_short_signature_rejected(self): + original = self.policy['keys']['kms-key']['publicKey'] + raw = bytearray(base64.urlsafe_b64decode(original+'='*(-len(original)%4))) + for changed in (bytes(raw)+b'\x00', bytes(raw[:16])+b'\x11'+bytes(raw[17:]), bytes(raw[22:])): + self.policy['keys']['kms-key']['publicKey'] = base64.urlsafe_b64encode(changed).rstrip(b'=').decode() + self.assertFalse(self.verify()['verified']) + self.policy['keys']['kms-key']['publicKey'] = original + self.assertFalse(self.verify(self.fixture['token'].rsplit('.',1)[0]+'.YWJj')['verified']) + + def test_key_revocation_and_wrong_audience_fail_closed(self): + self.policy['revokedKeys'] = ['kms-key'] + self.assertFalse(self.verify()['verified']) + self.policy.pop('revokedKeys') + self.policy['audiences'] = ['other'] + self.assertFalse(self.verify()['verified']) + + def test_missing_maintained_backend_is_not_success(self): + with patch.dict(os.environ, {'OPENSSL_BINARY': '/nonexistent/openssl'}): + self.assertFalse(self.verify()['verified']) + self.assertTrue(self.verify()['verified']) diff --git a/tests/test_observatory_ingestion.py b/tests/test_observatory_ingestion.py new file mode 100644 index 0000000..7af0d98 --- /dev/null +++ b/tests/test_observatory_ingestion.py @@ -0,0 +1,124 @@ +import base64 +import copy +import datetime as dt +import hashlib +from pathlib import Path +import tempfile +import unittest +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from tools.attestation import ReplayStore +from tools.observatory_ingestion import ingest_measurement + +NOW = dt.datetime(2026, 10, 12, tzinfo=dt.timezone.utc) +TRANSCRIPT = b'CONNECTION ESTABLISHED\nProtocol version: TLSv1.3\nCiphersuite: TLS_AES_256_GCM_SHA384\nSignature type: RSA-PSS\nVerification: OK\nNegotiated TLS1.3 group: X25519MLKEM768\nDONE\n' + + +class IngestionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.key = Ed25519PrivateKey.generate() + public = self.key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + self.trust = {'issuers': {'observatory': {'keys': {'probe': base64.urlsafe_b64encode(public).rstrip(b'=').decode()}, + 'subjects': ['https://example.org'], 'audiences': ['monitor'], 'purposes': ['pqc-observation']}}} + self.claims = {'iss': 'observatory', 'sub': 'https://example.org', 'aud': 'monitor', 'jti': 'measurement-1', + 'purpose': 'pqc-observation', 'iat': int(NOW.timestamp()), 'exp': int(NOW.timestamp())+3600, + 'observation': {'profile': 'openssl-tls13-v1', 'observedAt': NOW.isoformat(timespec='seconds'), + 'endpoint': {'host': 'example.org', 'port': 443, 'address': '192.0.2.1'}, + 'probe': {'name': 'openssl', 'version': 'OpenSSL 3.5.0', 'groups': ['X25519MLKEM768', 'X25519'], + 'certificateValidation': 'pkix-hostname', 'exitCode': 0}, + 'transcriptSha256': hashlib.sha256(TRANSCRIPT).hexdigest()}} + self.store = ReplayStore(Path(self.temp.name)/'replay.json') + + def ingest(self, claims=None, transcript=TRANSCRIPT): + token = jwt.encode(claims or self.claims, self.key, algorithm='EdDSA', headers={'typ': 'pqc-observation+jwt', 'kid': 'probe'}) + return ingest_measurement(token, transcript, self.trust, 'https://example.org', expected_audience='monitor', replay_store=self.store, now=NOW) + + def test_authenticated_measurement_is_not_independent_verification(self): + result = self.ingest() + self.assertTrue(result['accepted'], result) + self.assertTrue(result['issuerAuthenticated']) + self.assertTrue(result['observation']['hybridKexObserved']) + self.assertFalse(result['independentlyVerified']) + self.assertFalse(result['authenticationPqcVerified']) + self.assertEqual(self.ingest()['reason'], 'replay') + + def test_rejects_tampered_transcript(self): + self.assertFalse(self.ingest(transcript=TRANSCRIPT+b'!')['accepted']) + + def test_rejects_unbound_or_stale_measurements(self): + mutations = [('endpoint', {'host': 'other.org', 'port': 443, 'address': '192.0.2.1'}), + ('observedAt', '2026-10-01T00:00:00Z'), ('observedAt', '2026-10-12T00:00:01Z'), + ('observedAt', '2026-10-12T00:00:00'), ('profile', 'legacy-database-row')] + for i, (field, value) in enumerate(mutations): + with self.subTest(field=field, value=value): + claims = copy.deepcopy(self.claims) + claims['jti'] = str(i) + claims['observation'][field] = value + self.assertFalse(self.ingest(claims)['accepted']) + + def test_rejects_nonvalidated_and_failed_probes(self): + for i, (field, value) in enumerate([('exitCode', 1), ('exitCode', False), ('certificateValidation', 'disabled'), ('groups', ['X25519'])]): + with self.subTest(field=field): + claims = copy.deepcopy(self.claims) + claims['jti'] = str(i) + claims['observation']['probe'][field] = value + self.assertFalse(self.ingest(claims)['accepted']) + + def test_legacy_unsigned_row_is_rejected(self): + row = {'kexHybrid': True, 'kexGroup': 'X25519MLKEM768'} + result = ingest_measurement(row, TRANSCRIPT, self.trust, 'https://example.org', expected_audience='monitor', replay_store=self.store, now=NOW) + self.assertFalse(result['accepted']) + + def test_size_limit_before_signature(self): + result = ingest_measurement('', b'x'*65537, self.trust, 'https://example.org', expected_audience='monitor', replay_store=self.store, now=NOW) + self.assertEqual(result['reason'], 'invalid-transcript') + + def test_server_hello_without_completed_authentication_rejected(self): + transcript = b'ServerHello: X25519MLKEM768\nHelloRetryRequest\n' + claims = copy.deepcopy(self.claims) + claims['observation']['transcriptSha256'] = hashlib.sha256(transcript).hexdigest() + self.assertFalse(self.ingest(claims, transcript)['accepted']) + + def test_effective_validity_includes_local_freshness_policy(self): + self.trust['issuers']['observatory']['maxAgeSeconds'] = 120 + result = self.ingest() + self.assertEqual(result['validUntil'], int(NOW.timestamp())+120) + self.assertLess(result['validUntil'], result['expiresAt']) + + def test_monitor_retains_trusted_hybrid_baseline_through_replayed_evidence(self): + import json + from tools.monitor import run_once + root = Path(__file__).resolve().parents[1] + directory = Path(self.temp.name) + manifest = json.loads((root/'examples/crypto-agility-v2.json').read_text()) + manifest['subject']['id'] = 'https://example.org' + raw = json.dumps(manifest).encode() + manifest_path = directory/'manifest.json' + manifest_path.write_bytes(raw) + schema = json.loads((root/'schema/crypto-agility-v2.schema.json').read_text()) + claims = copy.deepcopy(self.claims) + claims['snapshotDigest'] = 'sha256:' + hashlib.sha256(raw).hexdigest() + evidence = {'audience':'monitor','replay_store':self.store} + for name, content in [('token',b''),('transcript',TRANSCRIPT),('trust',json.dumps(self.trust).encode())]: + evidence[name] = directory/name + evidence[name].write_bytes(content) + def sign(): + evidence['token'].write_text(jwt.encode(claims,self.key,algorithm='EdDSA',headers={'typ':'pqc-observation+jwt','kid':'probe'})) + sign() + state = directory/'monitor.json' + first = run_once(manifest_path,schema,state,NOW,evidence=evidence) + self.assertTrue(first['measurements'][0]['hybridKexObserved']) + replay = run_once(manifest_path,schema,state,NOW,evidence=evidence) + self.assertEqual(replay['measurements'],[]) + self.assertIn({'code':'measurement-rejected','ref':'replay'},replay['assessment']['findings']) + classical = TRANSCRIPT.replace(b'X25519MLKEM768',b'X25519') + evidence['transcript'].write_bytes(classical) + claims['jti'] = 'measurement-2' + claims['observation']['transcriptSha256'] = hashlib.sha256(classical).hexdigest() + sign() + final = run_once(manifest_path,schema,state,NOW,evidence=evidence) + self.assertFalse(final['measurements'][0]['hybridKexObserved']) + self.assertIn('tls-regression',{alert['type'] for alert in final['changes']['alerts']}) diff --git a/tests/test_openssl_observation.py b/tests/test_openssl_observation.py new file mode 100644 index 0000000..13e823a --- /dev/null +++ b/tests/test_openssl_observation.py @@ -0,0 +1,30 @@ +import unittest +from tools.openssl_observation import parse_openssl_observation +class ObservationTests(unittest.TestCase): + def test_hybrid_negotiated(self): + s="New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384\nNegotiated TLS1.3 group: X25519MLKEM768\nVerify return code: 0 (ok)" + r=parse_openssl_observation(s) + self.assertTrue(r["hybridKexObserved"]) + self.assertFalse(r["authenticationPqcVerified"]) + def test_offered_group_is_not_negotiated(self): + s="offered X25519MLKEM768\nNew, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384\nNegotiated TLS1.3 group: X25519\nVerify return code: 0 (ok)" + self.assertFalse(parse_openssl_observation(s)["hybridKexObserved"]) + def test_no_handshake(self): + self.assertEqual(parse_openssl_observation("X25519MLKEM768")["status"],"unknown") + def test_failed_cert(self): + s="New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384\nNegotiated TLS1.3 group: X25519MLKEM768\nVerify return code: 20 (unable to get local issuer certificate)" + self.assertEqual(parse_openssl_observation(s)["status"],"unknown") +if __name__=="__main__":unittest.main() + +class AdversarialObservationTests(unittest.TestCase): + def test_duplicate_group_rejected(self): + s='Protocol version: TLSv1.3\nCiphersuite: TLS_AES_256_GCM_SHA384\nVerification: OK\nPeer Temp Key: X25519, 253 bits\nNegotiated TLS1.3 group: X25519MLKEM768\n' + self.assertEqual(parse_openssl_observation(s)['status'],'unknown') + def test_success_banner_with_error_is_not_success(self): + s='Protocol version: TLSv1.3\nCiphersuite: TLS_AES_256_GCM_SHA384\nVerification: OK\nNegotiated TLS1.3 group: X25519MLKEM768\nverify error:num=62:hostname mismatch\n' + self.assertFalse(parse_openssl_observation(s)['certificateVerified']) + def test_cipher_none_is_not_completed_handshake(self): + s='New, TLSv1.3, Cipher is (NONE)\nNegotiated TLS1.3 group: X25519MLKEM768\nVerify return code: 0 (ok)' + self.assertFalse(parse_openssl_observation(s)['tls13Observed']) + def test_oversize_output(self): + with self.assertRaises(ValueError):parse_openssl_observation('x'*65537) diff --git a/tests/test_qscan.py b/tests/test_qscan.py new file mode 100644 index 0000000..5187427 --- /dev/null +++ b/tests/test_qscan.py @@ -0,0 +1,78 @@ +import copy +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from tools.qscan import convert_qscan, encode_manifest, QSCAN_SOURCE_COMMIT +from tools.evidence import verify_local_evidence + +ROOT = Path(__file__).resolve().parents[1] +RAW = (ROOT / 'tests/fixtures/qscan-v1.json').read_bytes() +PROFILE = {'id': 'source-project', 'deviceClass': 'server', 'endpointRole': 'server', 'protocol': 'unknown', + 'keyEstablishment': [], 'authentication': [], 'fallback': 'unknown'} +ARGS = {'repository': 'quantakrypto/test-fixture', 'commit': '1' * 40, 'profiles': [PROFILE], 'expires_at': '2026-10-13T00:00:00Z'} + + +class QscanTests(unittest.TestCase): + def test_reproducible_and_honest_output(self): + first = convert_qscan(RAW, **ARGS) + self.assertEqual(encode_manifest(first), encode_manifest(convert_qscan(RAW, **ARGS))) + self.assertEqual(first['generatedAt'], '2026-10-11T00:00:00Z') + methods = {o['capability']: o['method'] for o in first['observations']} + self.assertEqual(methods['source-uses:RSA'], 'inferred') + self.assertEqual(methods['hybrid-key-establishment'], 'declared') + self.assertNotIn('observed', methods.values()) + self.assertEqual(first['evidence'][0]['digest']['value'], hashlib.sha256(RAW).hexdigest()) + self.assertNotIn('attestation', first) + + def test_exact_bytes_evidence_and_tamper(self): + manifest = convert_qscan(RAW, **ARGS) + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / 'qscan-v1.json' + source.write_bytes(RAW) + self.assertEqual(verify_local_evidence(manifest, folder)[0]['status'], 'digest-valid') + source.write_bytes(RAW + b' ') + self.assertEqual(verify_local_evidence(manifest, folder)[0]['status'], 'digest-mismatch') + + def test_no_mutation_or_invented_runtime_profile(self): + before = copy.deepcopy(ARGS) + convert_qscan(RAW, **ARGS) + self.assertEqual(ARGS, before) + with self.assertRaises(ValueError): + convert_qscan(RAW, **{**ARGS, 'profiles': [{**PROFILE, 'evidenceIds': ['qscan-source']}]}) + + def test_identity_expiry_and_duplicate_rejected(self): + for changes in [{'repository': 'other'}, {'commit': 'bad'}, {'expires_at': '2026-10-10T00:00:00Z'}, {'profiles': [PROFILE, PROFILE]}]: + with self.subTest(changes=changes), self.assertRaises(ValueError): + convert_qscan(RAW, **{**ARGS, **changes}) + for raw in [b'{"version":1,"version":1}', b' ' * 1048577]: + with self.assertRaises(ValueError): + convert_qscan(raw, **ARGS) + + def test_absence_and_unknown_do_not_prove_security(self): + source = json.loads(RAW) + source['posture']['hybridKexInUse'] = None + source['cbomSummary']['algorithmFamilies'] = [] + result = convert_qscan(json.dumps(source).encode(), **ARGS) + self.assertEqual(len(result['observations']), 1) + self.assertEqual(result['observations'][0]['value'], 'unknown') + + def test_real_pinned_qscan_interoperability(self): + configured = os.environ.get('QSCAN_ROOT') + if not configured: + if os.environ.get('REQUIRE_QSCAN_INTEROP') == '1': + self.fail('QSCAN_ROOT required for integration acceptance') + self.skipTest('set QSCAN_ROOT to pinned pqc-tools checkout for real integration') + checkout = Path(configured).resolve() + head = subprocess.check_output(['git', '-C', str(checkout), 'rev-parse', 'HEAD'], text=True).strip() + self.assertEqual(head, QSCAN_SOURCE_COMMIT, 'interop producer must use pinned source') + command = ['node', str(ROOT / 'tests/qscan_interop.mjs')] + first = subprocess.check_output(command, cwd=ROOT, timeout=60) + second = subprocess.check_output(command, cwd=ROOT, timeout=60) + self.assertEqual(first, second, 'real producer must be reproducible for fixed inputs') + produced = convert_qscan(first, **ARGS) + self.assertTrue(any(o['capability'] == 'source-uses:RSA' for o in produced['observations'])) + self.assertEqual(encode_manifest(produced), encode_manifest(convert_qscan(second, **ARGS))) diff --git a/tests/test_semantic_security.py b/tests/test_semantic_security.py new file mode 100644 index 0000000..050bf9a --- /dev/null +++ b/tests/test_semantic_security.py @@ -0,0 +1,103 @@ +"""Meaningful acceptance tests at declaration, parser and local-file boundaries.""" +import copy +import datetime as dt +import hashlib +import json +import os +from pathlib import Path +import tempfile +import unittest +from tools.verifier import assess, load_manifest +from tools.compatibility import assess_pair +from tools.evidence import verify_local_evidence + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = json.loads((ROOT/'schema/crypto-agility-v2.schema.json').read_text()) +EXAMPLE = json.loads((ROOT/'examples/crypto-agility-v2.json').read_text()) +NOW = dt.datetime(2026, 10, 12, tzinfo=dt.timezone.utc) + +class SemanticSecurityTests(unittest.TestCase): + def codes(self, m): + return {f['code'] for f in assess(m, SCHEMA, NOW)['findings']} + + def test_stale_observation_not_refreshed_by_manifest(self): + m = copy.deepcopy(EXAMPLE) + m['generatedAt'] = '2026-10-12T00:00:00Z' + m['observations'][0]['observedAt'] = '2026-10-01T00:00:00Z' + self.assertIn('stale-observation', self.codes(m)) + + def test_contradictory_assertions_not_silently_selected(self): + m = copy.deepcopy(EXAMPLE) + m['observations'].append(dict(m['observations'][0], id='conflict', value='unsupported')) + self.assertIn('conflicting-observations', self.codes(m)) + + def test_exception_cannot_suppress_finding(self): + m = copy.deepcopy(EXAMPLE) + m['exceptions'] = [{'findingId':'fallback-risk','reason':'ignore','expiresAt':'2026-10-11T00:00:00Z'}] + self.assertTrue({'fallback-risk','expired-exception','unowned-exception'} <= self.codes(m)) + + def test_missing_evidence_subject_rejected(self): + m = copy.deepcopy(EXAMPLE) + m['evidence'] = [{'id':'e','mediaType':'application/json','digest':{'algorithm':'sha-256','value':'a'*64}}] + self.assertIn('schema', self.codes(m)) + + def test_profile_bound(self): + m = copy.deepcopy(EXAMPLE) + m['profiles'] = [dict(m['profiles'][0], id=str(i)) for i in range(65)] + self.assertEqual(assess(m, SCHEMA, NOW)['status'], 'invalid') + + def test_invalid_calendar_time_rejected(self): + m = copy.deepcopy(EXAMPLE) + m['generatedAt'] = '2026-02-31T00:00:00Z' + self.assertEqual(assess(m, SCHEMA, NOW)['status'], 'invalid') + + def test_strict_json(self): + for data in ('{"version":2,"version":1}', '{"x":NaN}', '['*17+']'*17, ' '*1048577): + with self.subTest(data=data[:30]), self.assertRaises(ValueError): + load_manifest(data) + self.assertEqual(load_manifest('{"x":"[[[[\\\""}'), {'x':'[[[["'}) + + def test_no_self_verified_method(self): + m = copy.deepcopy(EXAMPLE) + m['observations'][0]['method'] = 'verified' + self.assertEqual(assess(m, SCHEMA, NOW)['status'], 'invalid') + + def test_every_device_class_uses_capabilities_not_label(self): + server = {'endpointRole':'server','protocol':'TLS 1.3','keyEstablishment':['X25519MLKEM768'],'authentication':['ECDSA']} + for device in ('browser','server','mobile','network','iot','embedded','industrial','desktop','other'): + client = dict(server, endpointRole='client', deviceClass=device) + with self.subTest(device=device): + self.assertEqual(assess_pair(client,server)['status'],'declared-compatible') + client['constraints'] = ['offline or insufficient RAM; requires device test'] + self.assertEqual(assess_pair(client,server)['status'],'unknown') + + def test_gateway_is_only_single_hop(self): + c={'endpointRole':'client','protocol':'TLS 1.3','keyEstablishment':['X25519'],'authentication':['ECDSA']} + result=assess_pair(c,dict(c,endpointRole='gateway',deviceClass='network')) + self.assertEqual(result['scope'],'single-hop') + self.assertEqual(result['status'],'declared-compatible') + + def test_origin_binding_and_serialization(self): + m=copy.deepcopy(EXAMPLE) + m['subject']['kind']='origin' + for subject in ('https://EXAMPLE.org','https://example.org/','https://example.org:443','https://user@example.org','https://example.org:8443'): + m['subject']['id']=subject + self.assertIn('invalid-origin',self.codes(m)) + m['subject']['id']='https://example.org' + result=assess(m,SCHEMA,NOW,expected_subject='https://other.org') + self.assertIn('subject-mismatch',{f['code'] for f in result['findings']}) + + def test_absent_algorithm_inventory_is_unknown(self): + client = {'endpointRole':'client','protocol':'TLS 1.3','keyEstablishment':[],'authentication':['ECDSA']} + self.assertEqual(assess_pair(client,dict(client,endpointRole='server'))['status'],'unknown') + + def test_file_unsafe_paths_symlink_fifo_and_size(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root/'data').write_bytes(b'x'*20) + (root/'link').symlink_to(root/'data') + os.mkfifo(root/'pipe') + for uri in ('file:link','file:pipe','file:../data','file:/data','file:%2e%2e/data','file:data#ignored','file:data'): + item = {'id':'x','uri':uri,'digest':{'algorithm':'sha-256','value':hashlib.sha256(b'x'*20).hexdigest()}} + with self.subTest(uri=uri): + self.assertEqual(verify_local_evidence({'evidence':[item]},root,max_bytes=10)[0]['status'],'rejected') diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..16b2dd1 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,126 @@ +import tempfile,unittest +from pathlib import Path +from tools.state import compare,update_state +class StateTests(unittest.TestCase): + def test_regression(self): + a={"subject":"s","assessment":{"findings":[]},"pairs":[{"client":"c","server":"s","assessment":{"status":"declared-compatible"}}]} + b={"subject":"s","assessment":{"findings":[{"code":"expired","ref":"manifest"}]},"pairs":[{"client":"c","server":"s","assessment":{"status":"incompatible"}}]} + alerts=compare(a,b)["alerts"] + self.assertEqual(len(alerts),2) + def test_subject_binding(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/"state.json" + update_state(p,{"subject":"one","assessment":{"findings":[]},"pairs":[]}) + with self.assertRaises(ValueError): update_state(p,{"subject":"two","assessment":{"findings":[]},"pairs":[]}) + def test_invalid_does_not_replace_state(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/"state.json" + update_state(p,{"subject":"one","assessment":{"status":"unverified","findings":[]},"pairs":[]}) + original=p.read_bytes() + with self.assertRaises(ValueError): + update_state(p,{"subject":"one","assessment":{"status":"invalid","findings":[]},"pairs":[]}) + self.assertEqual(p.read_bytes(),original) + def test_baseline(self): + with tempfile.TemporaryDirectory() as d: + result=update_state(Path(d)/"state.json",{"subject":"one","assessment":{"findings":[]},"pairs":[]}) + self.assertTrue(result["initialBaseline"]) + +class SecureStateTests(unittest.TestCase): + def report(self, when='2026-10-12T00:00:00+00:00'): + return {'subject':'s','checkedAt':when,'manifestGeneratedAt':when,'assessment':{'status':'unverified','findings':[]},'pairs':[]} + def test_predictable_temp_symlink_cannot_modify_target(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json'; target=Path(d)/'valuable' + target.write_text('valuable');Path(str(p)+'.tmp').symlink_to(target) + update_state(p,self.report()) + self.assertEqual(target.read_text(),'valuable') + self.assertEqual(p.stat().st_mode&0o777,0o600) + def test_symlink_state_lock_and_fifo_rejected(self): + import os + for variant in ('state','lock','fifo'): + with self.subTest(variant=variant),tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json'; target=Path(d)/'target';target.write_text('{}');target.chmod(0o600) + if variant=='state':p.symlink_to(target) + elif variant=='lock':Path(str(p)+'.lock').symlink_to(target) + else:os.mkfifo(p,0o600) + with self.assertRaises((ValueError,OSError)):update_state(p,self.report()) + self.assertEqual(target.read_text(),'{}') + def test_clock_and_manifest_rollback(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json';update_state(p,self.report()) + with self.assertRaises(ValueError):update_state(p,self.report('2026-10-11T00:00:00Z')) + newer=self.report('2026-10-13T00:00:00Z');newer['manifestGeneratedAt']='2026-10-11T00:00:00Z' + with self.assertRaises(ValueError):update_state(p,newer) + def test_concurrent_locked_updates(self): + from concurrent.futures import ThreadPoolExecutor + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json' + with ThreadPoolExecutor(max_workers=4) as pool: + results=list(pool.map(lambda _:update_state(p,self.report()),range(20))) + self.assertEqual(sum(bool(r.get('initialBaseline')) for r in results),1) + def test_removed_coverage_and_alert_deduplication(self): + a=self.report(); a['profileIds']=['client'];a['pairs']=[{'client':'client','server':'server','assessment':{'status':'declared-compatible'}}] + b=self.report();b['profileIds']=[] + alerts=compare(a,b)['alerts'] + self.assertEqual(len(alerts),2) + self.assertTrue(all(x['action'] and x['id'] for x in alerts)) + self.assertEqual(compare(b,b)['alerts'],[]) + def test_measurement_loss_expiry_and_classical_regression(self): + a=self.report();a['measurements']=[{'issuer':'p','subject':'s','status':'authenticated-observation','negotiatedGroup':'X25519MLKEM768','hybridKexObserved':True}] + for state in ('stale','classical','removed'): + import copy + b=copy.deepcopy(a) + if state=='stale':b['measurements'][0]['status']='stale' + elif state=='classical':b['measurements'][0].update(negotiatedGroup='X25519',hybridKexObserved=False) + else:b['measurements']=[] + self.assertEqual(len(compare(a,b)['alerts']),1) + +class DeliveryStateTests(unittest.TestCase): + def report(self, findings=(), measurements=()): + return {'subject':'s','checkedAt':'2026-10-12T00:00:00Z','assessment':{'status':'needs-review','findings':list(findings)},'pairs':[],'measurements':list(measurements)} + def test_undelivered_alert_survives_restart_and_explicit_ack(self): + from tools.state import acknowledge_alerts + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json'; report=self.report([{'code':'expired','ref':'manifest'}]) + first=update_state(p,report)['alerts'] + retry=update_state(p,report)['alerts'] + self.assertEqual(first,retry) + self.assertEqual(len(first),1) + acknowledge_alerts(p,[first[0]['deliveryId']]) + self.assertEqual(update_state(p,report)['alerts'],[]) + def test_ack_is_bound_to_delivery_occurrence(self): + from tools.state import acknowledge_alerts + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json'; report=self.report([{'code':'expired','ref':'manifest'}]) + first=update_state(p,report)['alerts'][0] + acknowledge_alerts(p,[first['deliveryId']]) + update_state(p,self.report()) + again=update_state(p,report)['alerts'][0] + self.assertEqual(first['id'],again['id']) + self.assertNotEqual(first['deliveryId'],again['deliveryId']) + acknowledge_alerts(p,[first['deliveryId']]) + self.assertEqual(update_state(p,report)['alerts'],[again]) + def test_pending_alerts_survive_concurrent_ack_and_check(self): + from tools.state import acknowledge_alerts + from concurrent.futures import ThreadPoolExecutor + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json'; old=self.report([{'code':'expired','ref':'manifest'}]) + alert=update_state(p,old)['alerts'][0] + new=self.report([{'code':'expired','ref':'manifest'},{'code':'measurement-rejected','ref':'replay'}]) + with ThreadPoolExecutor(max_workers=2) as pool: + tasks=[pool.submit(acknowledge_alerts,p,[alert['deliveryId']]),pool.submit(update_state,p,new)] + for task in tasks:task.result() + pending=update_state(p,new)['alerts'] + self.assertEqual([a['code'] for a in pending],['measurement-rejected']) + def test_rejection_does_not_erase_last_authenticated_regression_baseline(self): + import json + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'state.json' + hybrid={'issuer':'issuer','subject':'s','status':'authenticated-observation','negotiatedGroup':'X25519MLKEM768','hybridKexObserved':True} + update_state(p,self.report(measurements=[hybrid])) + rejected=self.report([{'code':'measurement-rejected','ref':'replay'}]) + update_state(p,rejected) + self.assertEqual(json.loads(p.read_text())['measurements'],[]) + classical=dict(hybrid,negotiatedGroup='X25519',hybridKexObserved=False) + alerts=update_state(p,self.report(measurements=[classical]))['alerts'] + self.assertIn('tls-regression',{a['type'] for a in alerts}) diff --git a/tests/test_tls_live.py b/tests/test_tls_live.py new file mode 100644 index 0000000..8d22740 --- /dev/null +++ b/tests/test_tls_live.py @@ -0,0 +1,130 @@ +"""Actual local TLS handshakes. Hybrid is REQUIRED when REQUIRE_HYBRID_TLS=1.""" +import datetime as dt +import os +from pathlib import Path +import shutil +import socket +import subprocess +import tempfile +import time +import unittest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from tools.tls_probe import probe_openssl, _run_bounded + +OPENSSL = os.environ.get('OPENSSL_BINARY', 'openssl') + + +class LiveTLSTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which(OPENSSL): + raise RuntimeError('OpenSSL is required for TLS interoperability tests') + cls.temp = tempfile.TemporaryDirectory() + cls.root = Path(cls.temp.name) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')]) + now = dt.datetime.now(dt.timezone.utc) + cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now-dt.timedelta(days=1)) + .not_valid_after(now+dt.timedelta(days=1)).add_extension(x509.SubjectAlternativeName([x509.DNSName('localhost')]), False) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), True).sign(key, hashes.SHA256())) + (cls.root/'cert.pem').write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + expired = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now-dt.timedelta(days=2)) + .not_valid_after(now-dt.timedelta(days=1)).add_extension(x509.SubjectAlternativeName([x509.DNSName('localhost')]), False) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), True).sign(key, hashes.SHA256())) + (cls.root/'expired.pem').write_bytes(expired.public_bytes(serialization.Encoding.PEM)) + (cls.root/'key.pem').write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + def run_connection(self, server_groups, client_groups, *, trust=True, host='localhost', tls='-tls1_3', cert_name='cert.pem'): + with socket.socket() as reservation: + reservation.bind(('127.0.0.1', 0)) + port = reservation.getsockname()[1] + log = tempfile.TemporaryFile() + process = subprocess.Popen([OPENSSL, 's_server', '-accept', f'127.0.0.1:{port}', '-cert', str(self.root/cert_name), + '-key', str(self.root/'key.pem'), tls, '-groups', server_groups, '-quiet', '-www'], + stdin=subprocess.DEVNULL, stdout=log, stderr=log) + try: + for _ in range(100): + if process.poll() is not None: + log.seek(0) + raise RuntimeError(log.read().decode()) + try: + with socket.create_connection(('127.0.0.1', port), timeout=.05): + break + except OSError: + time.sleep(.01) + return probe_openssl(host, port, address='127.0.0.1', allow_private=True, groups=client_groups, + cafile=self.root/cert_name if trust else None, openssl=OPENSSL) + finally: + process.terminate() + process.wait(timeout=5) + log.close() + + def test_classical_authenticated_connection(self): + result = self.run_connection('X25519', ['X25519']) + self.assertEqual(result['status'], 'verified-connection', result) + self.assertFalse(result['hybridKexVerified']) + self.assertIn(result['peerSignatureType'], ('RSA-PSS', 'rsa_pss_rsae_sha256')) + + def test_untrusted_certificate_rejected(self): + result = self.run_connection('X25519', ['X25519'], trust=False) + self.assertFalse(result['certificateValidated']) + self.assertEqual(result['status'], 'failed') + + def test_hostname_mismatch_rejected(self): + result = self.run_connection('X25519', ['X25519'], host='wrong.example') + self.assertFalse(result['certificateValidated']) + + def test_tls12_server_rejected(self): + result = self.run_connection('X25519', ['X25519'], tls='-tls1_2') + self.assertEqual(result['status'], 'failed') + + def test_real_hybrid_and_fallback(self): + code, output = _run_bounded([OPENSSL, 'list', '-tls1_3', '-tls-groups'], 5) + if code != 0 or b'X25519MLKEM768' not in output: + if os.environ.get('REQUIRE_HYBRID_TLS') == '1': + self.fail('Required OpenSSL X25519MLKEM768 support is unavailable') + self.skipTest('OpenSSL lacks X25519MLKEM768; CI requires OpenSSL 3.5+') + hybrid = self.run_connection('X25519MLKEM768:X25519', ['X25519MLKEM768', 'X25519']) + self.assertTrue(hybrid['hybridKexVerified'], hybrid) + self.assertFalse(hybrid['authenticationPqcVerified']) + fallback = self.run_connection('X25519', ['X25519MLKEM768', 'X25519']) + self.assertTrue(fallback['classicalFallbackObserved'], fallback) + self.assertFalse(fallback['hybridKexVerified']) + denied = self.run_connection('X25519', ['X25519MLKEM768']) + self.assertEqual(denied['status'], 'failed') + + def test_expired_trusted_certificate_rejected(self): + result = self.run_connection('X25519', ['X25519'], cert_name='expired.pem') + self.assertFalse(result['certificateValidated']) + self.assertIn('expired', result['transcript']) + + def test_real_measurement_immediately_signed_and_ingested(self): + import base64 + import jwt + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from tools.attestation import ReplayStore + from tools.observatory_ingestion import ingest_measurement + result = self.run_connection('X25519', ['X25519']) + self.assertEqual(result['status'], 'verified-connection') + subject = 'https://localhost:' + str(result['port']) + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + trust = {'issuers': {'local-test': {'keys': {'key': base64.urlsafe_b64encode(public).rstrip(b'=').decode()}, + 'subjects': [subject], 'audiences': ['test-monitor'], 'purposes': ['pqc-observation']}}} + now = dt.datetime.now(dt.timezone.utc) + claims = {'iss': 'local-test', 'sub': subject, 'aud': 'test-monitor', 'purpose': 'pqc-observation', + 'jti': 'live-test', 'iat': int(now.timestamp()), 'exp': int(now.timestamp())+60, 'observation': result['evidence']} + token = jwt.encode(claims, key, algorithm='EdDSA', headers={'typ': 'pqc-observation+jwt', 'kid': 'key'}) + accepted = ingest_measurement(token, result['transcript'].encode(), trust, subject, + expected_audience='test-monitor', replay_store=ReplayStore(self.root/'live-replay.json'), now=now) + self.assertTrue(accepted['accepted'], accepted) + self.assertFalse(accepted['independentlyVerified']) diff --git a/tests/test_tls_probe.py b/tests/test_tls_probe.py new file mode 100644 index 0000000..fd347b1 --- /dev/null +++ b/tests/test_tls_probe.py @@ -0,0 +1,45 @@ +import unittest +from unittest.mock import patch +from tools.tls_probe import probe + +class ProbeTests(unittest.TestCase): + def test_rejects_invalid_parameters_without_network(self): + with self.assertRaises(ValueError): probe("example.org",port=0) + with self.assertRaises(ValueError): probe("example.org",timeout=100) + def test_probe_does_not_claim_pqc(self): + class Connection: + def __enter__(self):return self + def __exit__(self,*a):return False + def version(self):return "TLSv1.3" + def cipher(self):return ("TLS_AES_128_GCM_SHA256","TLSv1.3",128) + class Context: + minimum_version=None + maximum_version=None + def wrap_socket(self,*a,**k):return Connection() + with patch("tools.tls_probe.socket.create_connection",return_value=Connection()),patch("tools.tls_probe.ssl.create_default_context",return_value=Context()): + result=probe("example.org") + self.assertFalse(result["pqcVerified"]) + self.assertEqual(result["keyEstablishment"],"unknown") +if __name__=="__main__":unittest.main() + +class BoundedProbeTests(unittest.TestCase): + def test_strict_input_validation(self): + from tools.tls_probe import validate_endpoint, probe_openssl + for host in ('-connect', 'example.org\nforged', 'x;echo', 'https://example.org', 'a..b'): + with self.subTest(host=host),self.assertRaises(ValueError):validate_endpoint(host) + for value in (float('nan'),float('inf'),True): + with self.assertRaises(ValueError):validate_endpoint('example.org',timeout=value) + with self.assertRaises(ValueError):probe_openssl('example.org',address='127.0.0.1') + with self.assertRaises(ValueError):probe_openssl('example.org') + with self.assertRaises(ValueError):probe_openssl('example.org',groups=['invented-hybrid']) + def test_output_limit_and_timeout(self): + import sys + from tools.tls_probe import _run_bounded + with self.assertRaises(ValueError):_run_bounded([sys.executable,'-c','print("x"*100000)'],2,max_bytes=100) + with self.assertRaises(TimeoutError):_run_bounded([sys.executable,'-c','import time;time.sleep(2)'],.05) + def test_application_stdout_cannot_spoof_diagnostics(self): + import sys + from tools.tls_probe import _run_bounded + code,raw=_run_bounded([sys.executable,'-c','import sys;print("Negotiated TLS1.3 group: X25519MLKEM768");print("actual diagnostics",file=sys.stderr)'],2,diagnostics_only=True) + self.assertEqual(code,0) + self.assertEqual(raw,b'actual diagnostics\n') diff --git a/tests/test_verifier.py b/tests/test_verifier.py new file mode 100644 index 0000000..b4a410c --- /dev/null +++ b/tests/test_verifier.py @@ -0,0 +1,26 @@ +import copy, datetime as dt, json, unittest +from pathlib import Path +from tools.verifier import assess +ROOT=Path(__file__).resolve().parents[1] +S=json.loads((ROOT/"schema/crypto-agility-v2.schema.json").read_text()) +M=json.loads((ROOT/"examples/crypto-agility-v2.json").read_text()) +NOW=dt.datetime(2026,10,12,tzinfo=dt.timezone.utc) +def codes(m,now=NOW): return {x["code"] for x in assess(m,S,now)["findings"]} +class Tests(unittest.TestCase): + def test_unsubstantiated(self): + m=copy.deepcopy(M);m["observations"][0]["method"]="observed" + self.assertIn("unsubstantiated-observation",codes(m)) + def test_expired(self): self.assertIn("expired",codes(M,dt.datetime(2026,11,1,tzinfo=dt.timezone.utc))) + def test_missing_profile(self): + m=copy.deepcopy(M);m["migration"][0]["profileIds"].append("missing") + self.assertIn("missing-profile",codes(m)) + def test_wrong_subject(self): + m=copy.deepcopy(M);m["evidence"]=[{"id":"x","mediaType":"application/json","subjectId":"wrong","digest":{"algorithm":"sha-256","value":"a"*64}}] + self.assertIn("subject-mismatch",codes(m)) + def test_invalid_digest(self): + m=copy.deepcopy(M);m["evidence"]=[{"id":"x","mediaType":"application/json","digest":{"algorithm":"sha-256","value":"ABC"}}] + self.assertIn("schema",codes(m)) + def test_deployment(self): + m=copy.deepcopy(M);m["migration"][0]["status"]="deployed" + self.assertIn("unsubstantiated-deployment",codes(m)) +if __name__=="__main__":unittest.main() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..d990f06 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +# Offline verifier package diff --git a/tools/attestation.py b/tools/attestation.py new file mode 100644 index 0000000..92d16da --- /dev/null +++ b/tools/attestation.py @@ -0,0 +1,210 @@ +"""Strict observation JWT profile over RFC 7515/7519/8037/9964 (PyJWT). + +Ed25519 is classical; RFC9964 ML-DSA-65 is post-quantum. Neither establishes an independent +measurement, a certificate, or proof of post-quantum authentication. Never fetch +keys supplied by a token. Local policy removal revokes an issuer or key. +""" +import base64 +import datetime as dt +import hashlib +import json +import re +from pathlib import Path + +import jwt +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from tools.mldsa import MLDSA65Algorithm + +jwt.register_algorithm("ML-DSA-65", MLDSA65Algorithm()) +try: + from tools.state import canonical, locked_document +except ModuleNotFoundError: + from state import canonical, locked_document + +MAX_TOKEN_BYTES = 1048576 +TOKEN_TYPE = "pqc-observation+jwt" # local profile, not an IANA registration + + +def timestamp(value): + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timezone required") + return parsed.astimezone(dt.timezone.utc) + + +def _decode(segment): + if not isinstance(segment, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", segment): + raise ValueError("invalid base64url") + raw = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) + if base64.urlsafe_b64encode(raw).rstrip(b"=").decode() != segment: + raise ValueError("noncanonical base64url") + return raw + + +def _object(raw): + def unique(pairs): + obj = {} + for key, value in pairs: + if key in obj: + raise ValueError("duplicate JSON member") + obj[key] = value + return obj + def reject_constant(value): + raise ValueError("non-JSON number") + obj = json.loads(raw.decode("utf-8"), object_pairs_hook=unique, parse_constant=reject_constant) + if not isinstance(obj, dict): + raise ValueError("JSON object required") + return obj + + +def load_trust(path): + with Path(path).open("rb") as source: + raw = source.read(MAX_TOKEN_BYTES + 1) + if len(raw) > MAX_TOKEN_BYTES: + raise ValueError("trust configuration too large") + value = _object(raw) + if not isinstance(value.get("issuers"), dict): + raise ValueError("issuers required") + return value + + +class ReplayStore: + """Persistent, bounded, atomic jti consumption. Deletion requires rebootstrap. + + Fresh IDs do not permit an older issuance time to replace a newer statement + for the same issuer/subject/audience/purpose. A clock rollback fails closed. + """ + def __init__(self, path, max_entries=10000): + self.path = Path(path) + self.max_entries = max_entries + + def consume(self, claims, now): + identity = hashlib.sha256(canonical([claims[k] for k in ("iss", "aud", "jti")])).hexdigest() + scope = hashlib.sha256(canonical([claims[k] for k in ("iss", "sub", "aud", "purpose")])).hexdigest() + with locked_document(self.path) as (previous, save): + state = previous if previous is not None else {"version": 1, "lastNow": now, "entries": {}, "latest": {}} + if (state.get("version") != 1 or not isinstance(state.get("entries"), dict) + or not isinstance(state.get("latest"), dict)): + raise ValueError("invalid replay state") + if now < state["lastNow"]: + raise ValueError("replay store clock rollback") + state["entries"] = {key: expiry for key, expiry in state["entries"].items() if expiry > now} + if identity in state["entries"]: + return False, "replay" + if claims["iat"] < state["latest"].get(scope, -1): + return False, "observation-rollback" + if len(state["entries"]) >= self.max_entries or (scope not in state["latest"] and len(state["latest"]) >= self.max_entries): + raise ValueError("replay store capacity exceeded") + state["lastNow"] = now + state["entries"][identity] = claims["exp"] + state["latest"][scope] = claims["iat"] + save(state) + return True, "accepted" + + +def verify_envelope(envelope, trust_keys, expected_subject, + expected_purpose="pqc-observation", now=None, *, + expected_audience=None, replay_store=None, expected_snapshot=None, + expected_claims=None): + """Verify a compact JWS against operator policy, then atomically consume jti. + + Required claims: iss/sub/aud/iat/exp/jti/purpose/observation. Aud is one string. + Only locally pinned Ed25519 or ML-DSA-65 keys, never publisher key URLs. + Caller-specific signed bindings can be required with expected_claims. + Consuming a valid token followed by rejecting measurement semantics is safe: + the issuer must produce a new observation, never reuse that token's jti. + """ + def reject(reason): + return {"verified": False, "reason": reason} + if expected_audience is None or replay_store is None: + return reject("audience-and-replay-policy-required") + if not isinstance(envelope, str) or len(envelope) > MAX_TOKEN_BYTES: + return reject("invalid-envelope") + try: + instant = now or dt.datetime.now(dt.timezone.utc) + if instant.tzinfo is None: + return reject("timezone-required") + seconds = instant.timestamp() + pieces = envelope.split(".") + if len(pieces) != 3: + return reject("invalid-envelope") + header, untrusted = _object(_decode(pieces[0])), _object(_decode(pieces[1])) + signature = _decode(pieces[2]) + if (set(header) != {"alg", "kid", "typ"} or header["alg"] not in ("EdDSA", "ML-DSA-65") + or header["typ"] != TOKEN_TYPE or not isinstance(header["kid"], str) + or len(signature) != (64 if header["alg"] == "EdDSA" else 3309)): + return reject("unsupported-header") + required = ("iss", "sub", "aud", "iat", "exp", "jti", "purpose", "observation") + if any(name not in untrusted for name in required): + return reject("missing-claim") + for name in ("iss", "sub", "aud", "jti", "purpose"): + if not isinstance(untrusted[name], str) or not 1 <= len(untrusted[name]) <= 2048: + return reject("invalid-claim") + if not isinstance(untrusted["observation"], dict): + return reject("invalid-observation") + policy = trust_keys.get("issuers", {}).get(untrusted["iss"]) + if not isinstance(policy, dict) or policy.get("disabled", False): + return reject("untrusted-issuer") + for name in ("subjects", "audiences", "purposes", "revokedKeys"): + values = policy.get(name, [] if name == "revokedKeys" else None) + if not isinstance(values, list) or any(not isinstance(x, str) or not x for x in values): + return reject("invalid-trust-policy") + if not isinstance(policy.get("keys"), dict) or type(policy.get("disabled", False)) is not bool: + return reject("invalid-trust-policy") + key_bytes = policy["keys"].get(header["kid"]) + if key_bytes is None or header["kid"] in policy.get("revokedKeys", []): + return reject("untrusted-key") + if (expected_subject not in policy.get("subjects", []) + or expected_audience not in policy.get("audiences", []) + or expected_purpose not in policy.get("purposes", [])): + return reject("issuer-not-authorized") + # Historical string entries authorize EdDSA only, never another algorithm. + if isinstance(key_bytes, str): + key_policy = {"alg": "EdDSA", "format": "raw", "publicKey": key_bytes} + elif isinstance(key_bytes, dict) and set(key_bytes) == {"alg", "format", "publicKey"}: + key_policy = key_bytes + else: + return reject("invalid-trust-policy") + if key_policy["alg"] != header["alg"]: + return reject("key-algorithm-mismatch") + if header["alg"] == "EdDSA": + if key_policy["format"] != "raw": + return reject("invalid-trust-policy") + key = Ed25519PublicKey.from_public_bytes(_decode(key_policy["publicKey"])) + else: + if key_policy["format"] != "spki-der": + return reject("invalid-trust-policy") + key = _decode(key_policy["publicKey"]) + claims = jwt.decode(envelope, key, algorithms=[key_policy["alg"]], + issuer=untrusted["iss"], audience=expected_audience, + options={"verify_iat": False, "verify_exp": False, + "verify_nbf": False, "strict_aud": True}) + if claims["sub"] != expected_subject or claims["purpose"] != expected_purpose: + return reject("scope-mismatch") + for name in ("iat", "exp", "nbf"): + if name in claims and (type(claims[name]) is not int or claims[name] < 0): + return reject("invalid-time") + age = policy.get("maxAgeSeconds", 3600) + lifetime = policy.get("maxLifetimeSeconds", 3600) + if type(age) is not int or type(lifetime) is not int or not 0 < age <= 604800 or not 0 < lifetime <= 604800: + return reject("invalid-trust-policy") + if (claims["iat"] > seconds or claims["exp"] <= seconds + or claims["exp"] <= claims["iat"] or seconds - claims["iat"] > age + or claims["exp"] - claims["iat"] > lifetime + or claims.get("nbf", claims["iat"]) > seconds): + return reject("stale-or-future") + bindings = dict(expected_claims or {}) + if expected_snapshot is not None: + bindings["snapshotDigest"] = expected_snapshot + if any(claims.get(name) != value for name, value in bindings.items()): + return reject("binding-mismatch") + accepted, reason = replay_store.consume(claims, seconds) + if not accepted: + return reject(reason) + return {"verified": True, "reason": "signature-scope-freshness-and-replay-valid", + "claims": claims, "signingAlgorithm": "Ed25519" if header["alg"] == "EdDSA" else "ML-DSA-65", + "limitation": "Authenticated issuer statement; not independent measurement or proof of endpoint authentication"} + except (ValueError, TypeError, KeyError, AttributeError, RecursionError, jwt.PyJWTError): + return reject("invalid-signature-claims-or-policy") + except OSError: + return reject("replay-state-unavailable") diff --git a/tools/compatibility.py b/tools/compatibility.py new file mode 100644 index 0000000..2d6e070 --- /dev/null +++ b/tools/compatibility.py @@ -0,0 +1,28 @@ +"""Conservative declared-profile intersection across endpoint classes. + +Device labels never establish capability. Gateway termination covers one hop only. +Free-text resource/firmware constraints cannot be safely evaluated automatically. +""" + +def assess_pair(client, server): + if client.get('endpointRole') not in ('client', 'peer') or server.get('endpointRole') not in ('server', 'peer', 'gateway'): + return {'status': 'unknown', 'reason': 'endpoint roles not compatible with this evaluator'} + if not client.get('protocol') or not server.get('protocol'): + return {'status': 'unknown', 'reason': 'protocol unspecified'} + if client['protocol'] != server['protocol']: + return {'status': 'incompatible', 'reason': 'protocol mismatch'} + for endpoint in (client, server): + for field in ('keyEstablishment', 'authentication'): + values = endpoint.get(field) + if not isinstance(values, list) or not values or any(not isinstance(x, str) or not x for x in values): + return {'status': 'unknown', 'reason': 'algorithm inventory incomplete'} + common_kex = sorted(set(client['keyEstablishment']) & set(server['keyEstablishment'])) + common_auth = sorted(set(client['authentication']) & set(server['authentication'])) + if not common_kex or not common_auth: + return {'status': 'incompatible', 'reason': 'no shared declared key establishment or authentication'} + if client.get('constraints') or server.get('constraints'): + return {'status': 'unknown', 'reason': 'resource, connectivity or firmware constraints require explicit testing', + 'keyEstablishment': common_kex, 'authentication': common_auth} + return {'status': 'declared-compatible', 'keyEstablishment': common_kex, 'authentication': common_auth, + 'reason': 'Intersection only; runtime negotiation, certificate trust, resource limits and fallback remain unverified', + 'scope': 'single-hop'} diff --git a/tools/discovery.py b/tools/discovery.py new file mode 100644 index 0000000..97b3412 --- /dev/null +++ b/tools/discovery.py @@ -0,0 +1,246 @@ +"""Bounded HTTPS discovery. HTTP freshness never extends evidence freshness.""" +from dataclasses import dataclass +import datetime as dt +from email.utils import parsedate_to_datetime +import http.client +import ipaddress +import json +import multiprocessing +from pathlib import Path +import re +import socket +import ssl +import time +from urllib.parse import urlsplit + +from jsonschema import Draft202012Validator, FormatChecker, ValidationError +from tools.verifier import parse_time, load_manifest + +WELL_KNOWN = '/.well-known/crypto-agility.json' +SCHEMA = json.loads((Path(__file__).resolve().parents[1] / 'schema/crypto-agility-v2.schema.json').read_text()) + + +# The same bounded parser is used by offline and remote consumers. +strict_json = load_manifest + + +def normalize_origin(origin): + if not isinstance(origin, str) or not origin.isascii() or any(ord(c) < 33 or ord(c) == 127 for c in origin): + raise ValueError('invalid origin') + parts = urlsplit(origin) + if (parts.scheme != 'https' or not parts.hostname or parts.username is not None + or parts.password is not None or parts.path not in ('', '/') or parts.query or parts.fragment + or parts.port not in (None, 443)): + raise ValueError('HTTPS origin on port 443 required') + host = parts.hostname.lower() + if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', host): + raise ValueError('DNS hostname required') + return 'https://' + host + + +def _public_addresses(host): + answers = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) + ips = sorted({item[4][0] for item in answers}) + if not ips or len(ips) > 32: + raise ValueError('invalid DNS address count') + # Reject mixed public/private answers, mapped IPv6 and transition mechanisms. + for value in ips: + ip = ipaddress.ip_address(value) + if not ip.is_global or ip.is_multicast or ip.is_reserved or getattr(ip, 'ipv4_mapped', None) or getattr(ip, 'sixtofour', None) or getattr(ip, 'teredo', None): + raise ValueError('non-public network destination') + return ips + + +class _PinnedHTTPS(http.client.HTTPSConnection): + def __init__(self, host, address, timeout): + super().__init__(host, 443, timeout=timeout, context=ssl.create_default_context()) + self.address = address + + def connect(self): + # Connect only to the vetted literal. SNI and certificate validation still + # use the original hostname. Never resolve a second time or use proxies. + raw = socket.create_connection((self.address, 443), self.timeout) + try: + self.sock = self._context.wrap_socket(raw, server_hostname=self.host) + except BaseException: + raw.close() + raise + + +def _request_in_worker(host, headers, timeout, max_bytes): + ips = _public_addresses(host) + connection = _PinnedHTTPS(host, ips[0], timeout) + deadline = time.monotonic() + timeout + try: + connection.request('GET', WELL_KNOWN, headers=headers) + response = connection.getresponse() + raw_headers = response.getheaders() + result_headers = {} + for key, value in raw_headers: + key = key.lower() + if key in result_headers: + raise ValueError('duplicate HTTP response header') + result_headers[key] = value + if response.status != 200: + return response.status, result_headers, b'' + if result_headers.get('content-encoding', 'identity').lower() != 'identity': + raise ValueError('compressed response not accepted') + length = result_headers.get('content-length') + if length is not None and (not length.isdecimal() or int(length) > max_bytes): + raise ValueError('response length limit') + chunks, size = [], 0 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError('response deadline') + if connection.sock: + connection.sock.settimeout(remaining) + # read1 returns after one underlying read, so trickle traffic cannot + # restart the total response deadline for each received byte. + chunk = response.read1(min(65536, max_bytes + 1 - size)) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + if size > max_bytes: + raise ValueError('response size limit') + if length is not None and size != int(length): + raise ValueError('truncated response') + return response.status, result_headers, b''.join(chunks) + finally: + connection.close() + + +def _worker(pipe, host, headers, timeout, max_bytes): + try: + pipe.send(('ok', _request_in_worker(host, headers, timeout, max_bytes))) + except Exception as exc: + pipe.send(('error', type(exc).__name__ + ': ' + str(exc))) + finally: + pipe.close() + + +def _request(host, headers, timeout, max_bytes): + # A process deadline bounds DNS, TLS, slow response headers, and body reads. + # Socket timeouts alone do not bound trickle traffic or system DNS lookup. + context = multiprocessing.get_context('spawn') + receive, send = context.Pipe(duplex=False) + process = context.Process(target=_worker, args=(send, host, headers, timeout, max_bytes), daemon=True) + process.start() + send.close() + try: + if not receive.poll(timeout): + raise TimeoutError('HTTPS discovery total deadline') + try: + state, result = receive.recv() + except EOFError as exc: + raise ValueError('discovery worker exited without a response') from exc + if state != 'ok': + raise ValueError(result) + return result + finally: + receive.close() + if process.is_alive(): + process.terminate() + process.join(timeout=1) + if process.is_alive(): + process.kill() + process.join(timeout=1) + + +@dataclass(frozen=True) +class _Entry: + body: bytes + headers: dict + fresh_until: dt.datetime + + +class DiscoveryClient: + """Explicit allowlist, memory-only cache; no network use until fetch(). + + A scheduler may set force=True on a locally authenticated change hint. Hints + never supply a URL or posture; refetch the configured origin instead. + """ + def __init__(self, allowed_origins, *, timeout=10, max_bytes=1048576, max_age=300): + self.allowed = frozenset(normalize_origin(x) for x in allowed_origins) + if not self.allowed or len(self.allowed) > 256: + raise ValueError('one to 256 origins required') + if not 0 < timeout <= 60 or not 0 < max_bytes <= 1048576 or not 0 <= max_age <= 3600: + raise ValueError('invalid resource limits') + self.timeout, self.max_bytes, self.max_age = timeout, max_bytes, max_age + self._cache = {} + + def _validate(self, body, subject, now): + manifest = strict_json(body) + try: + Draft202012Validator(SCHEMA, format_checker=FormatChecker()).validate(manifest) + except ValidationError as exc: + raise ValueError('invalid manifest schema: ' + exc.message[:256]) from exc + if manifest['subject']['id'] != subject: + raise ValueError('subject mismatch') + generated, expires = parse_time(manifest['generatedAt']), parse_time(manifest['expiresAt']) + if generated > now + dt.timedelta(minutes=5) or expires <= now or expires <= generated: + raise ValueError('manifest expired or future dated') + return manifest + + def fetch(self, origin, expected_subject, *, now=None, force=False): + origin = normalize_origin(origin) + if origin not in self.allowed: + raise ValueError('origin not allowed') + now = now or dt.datetime.now(dt.timezone.utc) + if now.tzinfo is None: + raise ValueError('timezone required') + key = (origin, expected_subject) + entry = self._cache.get(key) + if entry and now < entry.fresh_until and not force: + return {'manifest': self._validate(entry.body, expected_subject, now), 'source': 'cache', 'body': entry.body} + headers = {'Accept': 'application/json', 'Accept-Encoding': 'identity'} + if entry: + if entry.headers.get('etag'): + headers['If-None-Match'] = entry.headers['etag'] + elif entry.headers.get('last-modified'): + headers['If-Modified-Since'] = entry.headers['last-modified'] + status, received, body = _request(urlsplit(origin).hostname, headers, self.timeout, self.max_bytes) + if status == 304: + if not entry or not ('If-None-Match' in headers or 'If-Modified-Since' in headers): + raise ValueError('unsolicited 304') + body = entry.body + received = {**entry.headers, **received} + elif status != 200: + # Includes every redirect. No stale-on-error and no destination follow. + raise ValueError('unexpected HTTP status ' + str(status)) + if len(body) > self.max_bytes: + raise ValueError('response size limit') + if received.get('content-type', '').split(';')[0].strip().lower() != 'application/json': + raise ValueError('application/json required') + manifest = self._validate(body, expected_subject, now) + for name in ('etag', 'last-modified'): + value = received.get(name, '') + if len(value) > 512 or any(ord(c) < 32 or ord(c) > 126 for c in value): + raise ValueError('unsafe HTTP validator') + directives = [x.strip().lower() for x in received.get('cache-control', '').split(',')] + ttl = 0 + max_ages = [x.partition('=')[2].strip('"') for x in directives if x.startswith('max-age=')] + if len(max_ages) == 1 and max_ages[0].isdecimal(): + age = received.get('age', '0') + if age.isdecimal(): + current_age = int(age) + if received.get('date'): + try: + server_date = parsedate_to_datetime(received['date']) + current_age = max(current_age, int((now - server_date).total_seconds())) + except (TypeError, ValueError, OverflowError): + current_age = self.max_age + ttl = max(0, min(int(max_ages[0]), self.max_age) - current_age) + if 'no-cache' in directives: + ttl = 0 + if 'no-store' in directives: + self._cache.pop(key, None) + else: + # One entry per allowed origin prevents attacker-controlled subjects + # from growing the cache without bound. + for old in list(self._cache): + if old[0] == origin: + del self._cache[old] + self._cache[key] = _Entry(body, dict(received), min(now + dt.timedelta(seconds=ttl), parse_time(manifest['expiresAt']))) + return {'manifest': manifest, 'source': 'revalidated' if status == 304 else 'http', 'body': body} diff --git a/tools/evidence.py b/tools/evidence.py new file mode 100644 index 0000000..af325f4 --- /dev/null +++ b/tools/evidence.py @@ -0,0 +1,61 @@ +"""Offline byte integrity checks. Digest validity is not issuer authenticity.""" +import hashlib +import os +from pathlib import Path +import re +import stat + +ALGORITHMS = {'sha-256': 'sha256', 'sha-384': 'sha384', 'sha-512': 'sha512'} + + +def verify_local_evidence(manifest, evidence_root, max_bytes=8*1024*1024): + """Open relative local evidence with descriptor-relative no-symlink traversal. + + No remote URL is fetched. Files are read at most max_bytes+1 even if growing. + The evidence directory is provisioned by the local operator, never a manifest. + """ + if not isinstance(max_bytes, int) or max_bytes < 1: + raise ValueError('invalid evidence byte limit') + root = Path(evidence_root) + results = [] + for item in manifest.get('evidence', []): + eid = item.get('id', '') + uri = item.get('uri', '') + if not isinstance(uri, str) or not uri.startswith('file:'): + results.append({'id': eid, 'status': 'unavailable', 'reason': 'local file URI required'}) + continue + relative = uri[5:] + parts = relative.split('/') + if not parts or any(p in ('', '.', '..') for p in parts) or any(c in relative for c in '\\%?#\x00'): + results.append({'id': eid, 'status': 'rejected', 'reason': 'absolute or ambiguous path'}) + continue + handles = [] + try: + digest = item['digest'] + h = hashlib.new(ALGORITHMS[digest['algorithm']]) + if not re.fullmatch('[0-9a-f]{%d}' % (h.digest_size*2), digest['value']): + raise ValueError('invalid digest') + handles.append(os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)) + for part in parts[:-1]: + handles.append(os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=handles[-1])) + # NONBLOCK avoids hanging on an attacker-supplied FIFO before fstat. + handles.append(os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=handles[-1])) + fd = handles[-1] + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise ValueError('regular file required') + count = 0 + while True: + chunk = os.read(fd, min(65536, max_bytes + 1 - count)) + if not chunk: + break + count += len(chunk) + if count > max_bytes: + raise ValueError('evidence too large') + h.update(chunk) + results.append({'id': eid, 'status': 'digest-valid' if h.hexdigest() == digest['value'] else 'digest-mismatch'}) + except (OSError, ValueError, TypeError, KeyError): + results.append({'id': eid, 'status': 'rejected', 'reason': 'unsafe path, malformed digest or oversized evidence'}) + finally: + for fd in reversed(handles): + os.close(fd) + return results diff --git a/tools/mldsa.py b/tools/mldsa.py new file mode 100644 index 0000000..bb1ec7f --- /dev/null +++ b/tools/mldsa.py @@ -0,0 +1,62 @@ +"""RFC 9964 ML-DSA-65 JWS verification using the OpenSSL 3.5 provider. + +The algorithm uses pure ML-DSA with an empty context, never HashML-DSA. +Only signature verification is exposed. Key provisioning remains out of band. +""" +import os +from pathlib import Path +import subprocess +import tempfile + +from jwt.algorithms import Algorithm +from tools.tls_probe import _run_bounded + +# RFC 9881 Sections 2/4: DER SubjectPublicKeyInfo for OID +# 2.16.840.1.101.3.4.3.18, absent parameters, 1952-byte public key. +# The fixed prefix checks key family/DER framing; OpenSSL parses/verifies the key. +SPKI_PREFIX = bytes.fromhex('308207b2300b0609608648016503040312038207a100') +PUBLIC_KEY_BYTES = 1952 +SIGNATURE_BYTES = 3309 + + +class MLDSA65Algorithm(Algorithm): + """PyJWT algorithm adapter; every cryptographic operation is OpenSSL-owned.""" + def prepare_key(self, key): + if (not isinstance(key, bytes) or len(key) != len(SPKI_PREFIX) + PUBLIC_KEY_BYTES + or not key.startswith(SPKI_PREFIX)): + raise ValueError('RFC9881 ML-DSA-65 SPKI DER key required') + return key + + def verify(self, msg, key, sig): + key = self.prepare_key(key) + if not isinstance(msg, bytes) or len(msg) > 1048576 or len(sig) != SIGNATURE_BYTES: + return False + # OPENSSL_BINARY is operator configuration, never taken from token/policy. + binary = os.environ.get('OPENSSL_BINARY', 'openssl') + with tempfile.TemporaryDirectory(prefix='pqc-jws-') as directory: + root = Path(directory) + (root/'key.der').write_bytes(key) + (root/'message').write_bytes(msg) + (root/'signature').write_bytes(sig) + # No digest or context option: OpenSSL's pure ML-DSA empty-context + # defaults match RFC9964. Explicit default provider excludes plugins. + try: + code, _ = _run_bounded([ + binary, 'pkeyutl', '-verify', '-rawin', '-pubin', + '-inkey', str(root/'key.der'), '-keyform', 'DER', + '-in', str(root/'message'), '-sigfile', str(root/'signature'), + '-provider', 'default', '-propquery', 'provider=default'], 5, 4096) + except (OSError, ValueError, subprocess.SubprocessError): + return False + return code == 0 + + def sign(self, msg, key): + raise NotImplementedError('signing belongs to the configured authority') + + @staticmethod + def to_jwk(key_obj, as_dict=False): + raise NotImplementedError('use independently provisioned SPKI DER trust keys') + + @staticmethod + def from_jwk(jwk): + raise NotImplementedError('token-provided JWKs are not accepted') diff --git a/tools/monitor.py b/tools/monitor.py new file mode 100644 index 0000000..eb2f1e0 --- /dev/null +++ b/tools/monitor.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Repeatable bounded local snapshot monitor. JSON alerts go to stdout or a file. + +A supervisor supplies scheduling and alert delivery. Monitoring does not fetch +untrusted evidence or turn declared profiles into verified TLS negotiations. +""" +import argparse +import datetime as dt +import json +import hashlib +import sys +from pathlib import Path +import time +try: + from tools.verifier import assess, load_manifest + from tools.attestation import ReplayStore + from tools.observatory_ingestion import ingest_measurement + from tools.discovery import DiscoveryClient + from tools.compatibility import assess_pair + from tools.state import update_state, fingerprint, acknowledge_alerts, enqueue_failure +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from tools.attestation import ReplayStore + from tools.observatory_ingestion import ingest_measurement + from tools.discovery import DiscoveryClient + from verifier import assess, load_manifest + from compatibility import assess_pair + from state import update_state, fingerprint, acknowledge_alerts, enqueue_failure + + +def snapshot(manifest, schema, now, *, authenticated_observations=()): + """authenticated_observations must be in-process ingest_measurement successes. + + These are trusted function results, never entries read from a manifest/file. + The CLI intentionally does not accept purported authenticated dictionaries. + """ + result = assess(manifest, schema, now) + manifest = manifest if isinstance(manifest, dict) else {} + profiles = manifest.get("profiles", []) if result["status"] != "invalid" else [] + clients = [p for p in profiles if p.get("endpointRole") in ("client", "peer")] + servers = [p for p in profiles if p.get("endpointRole") in ("server", "gateway", "peer")] + if len(clients) * len(servers) > 10000: + raise ValueError("compatibility assessment exceeds 10000 pairs") + pairs = [{"client": c.get("id"), "server": s.get("id"), "assessment": assess_pair(c, s)} + for c in clients for s in servers if c is not s] + subject = manifest.get("subject") + subject_id = subject.get("id") if isinstance(subject, dict) else None + measurements = {} + for item in authenticated_observations: + if item.get("accepted") is not True or item.get("issuerAuthenticated") is not True or item.get("subject") != subject_id: + raise ValueError("only authenticated ingestion results for the manifest subject are accepted") + observed = dt.datetime.fromisoformat(item["observedAt"].replace("Z", "+00:00")) + if observed.tzinfo is None or observed > now: + raise ValueError("invalid authenticated observation time") + summary = {"issuer": item["issuer"], "subject": subject_id, "observedAt": item["observedAt"], + "expiresAt": item["expiresAt"], "validUntil": item["validUntil"], + "status": "stale" if min(item["expiresAt"], item["validUntil"]) <= now.timestamp() else "authenticated-observation", + "negotiatedGroup": item["observation"]["negotiatedGroup"], + "hybridKexObserved": item["observation"]["hybridKexObserved"], + "independentlyVerified": False} + key = (item["issuer"], subject_id) + if key in measurements: + raise ValueError("duplicate collector measurements require explicit selection") + measurements[key] = summary + return {"subject": subject_id, "measurements": list(measurements.values()), + "checkedAt": now.isoformat(), "manifestGeneratedAt": manifest.get("generatedAt"), + "profileIds": sorted(p["id"] for p in profiles), "assessment": result, "pairs": pairs} + + +def _bounded_read(path, limit=1048576): + with Path(path).open("rb") as source: + raw = source.read(limit + 1) + if len(raw) > limit: + raise ValueError("input exceeds size limit") + return raw + + +def run_once(manifest_path, schema, state_path=None, now=None, *, evidence=None, + measurement_cache=None, discovery=None, origin=None, expected_subject=None): + """Read/refresh, authenticate, assess and atomically record one check. + + evidence contains token/transcript/trust paths, audience and ReplayStore. + A process-local cache reuses only identical authenticated input bytes and + policy. Expiry is reevaluated each cycle; a restart requires a fresh JWT. + """ + now = now or dt.datetime.now(dt.timezone.utc) + try: + if discovery is not None: + fetched = discovery.fetch(origin, expected_subject, now=now) + manifest, raw = fetched["manifest"], fetched["body"] + else: + raw = _bounded_read(manifest_path) + manifest = load_manifest(raw) + report = snapshot(manifest, schema, now) + measurements = [] + rejection = None + if evidence and report["assessment"]["status"] != "invalid": + token = _bounded_read(evidence["token"]).decode("ascii").strip() + transcript = _bounded_read(evidence["transcript"], 65536) + trust_raw = _bounded_read(evidence["trust"]) + trust = load_manifest(trust_raw) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + cache_key = fingerprint([token, hashlib.sha256(transcript).hexdigest(), + hashlib.sha256(trust_raw).hexdigest(), digest, + evidence["audience"], report["subject"]]) + cached = (measurement_cache or {}).get(cache_key) + if cached is not None: + observed = dt.datetime.fromisoformat(cached["observedAt"].replace("Z", "+00:00")) + if now < observed: + raise ValueError("monitor clock rollback") + measurements = [cached] + else: + result = ingest_measurement(token, transcript, trust, report["subject"], + expected_audience=evidence["audience"], + replay_store=evidence["replay_store"], now=now, + expected_snapshot=digest) + if result["accepted"]: + measurements = [result] + if measurement_cache is not None: + measurement_cache.clear() + measurement_cache[cache_key] = result + else: + rejection = result["reason"] + report = snapshot(manifest, schema, now, authenticated_observations=measurements) + if rejection: + report["assessment"]["findings"].append({"code": "measurement-rejected", "ref": rejection}) + report["assessment"]["status"] = "needs-review" + elif any(m["status"] == "stale" for m in report["measurements"]): + report["assessment"]["findings"].append({"code": "measurement-expired", "ref": report["subject"]}) + report["assessment"]["status"] = "needs-review" + if state_path and report["assessment"]["status"] != "invalid": + report["changes"] = update_state(state_path, report) + except (OSError, ValueError, TypeError, KeyError, RecursionError) as exc: + finding = {"code": "monitor-input-or-state-failure", "message": str(exc)} + report = {"subject": None, "checkedAt": now.isoformat(), + "assessment": {"status": "invalid", "findings": [finding]}, "pairs": []} + if report["assessment"]["status"] == "invalid": + alert = {"type": "monitor-failure", "severity": "error", + "action": "Repair the input or state failure; the previous baseline was retained."} + alert["id"] = fingerprint({"type": alert["type"], "findings": report["assessment"]["findings"]}) + report["changes"] = {"alerts": [alert], "resolved": []} + if state_path: + try: + report["changes"]["alerts"] = enqueue_failure(state_path, alert) + except (OSError, ValueError, TypeError, KeyError, RecursionError) as exc: + # State failure cannot promise durability. Keep an explicit output + # signal for the supervisor rather than silently losing the failure. + report["changes"]["deliveryPersistenceFailure"] = str(exc) + return report + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path, nargs="?") + parser.add_argument("--origin", help="explicit public HTTPS discovery origin") + parser.add_argument("--subject", help="expected manifest subject for remote discovery") + parser.add_argument("--token", type=Path, help="locally supplied compact observation JWT") + parser.add_argument("--transcript", type=Path) + parser.add_argument("--trust", type=Path, help="operator-provisioned issuer configuration") + parser.add_argument("--audience") + parser.add_argument("--replay-state", type=Path) + parser.add_argument("--schema", type=Path, default=Path("schema/crypto-agility-v2.schema.json")) + parser.add_argument("--output", type=Path) + parser.add_argument("--state", type=Path, help="owner-only local state for regression detection") + parser.add_argument("--ack-alert", action="append", default=[], metavar="DELIVERY_ID", + help="with --state alone, acknowledge this deliveryId only after confirmed delivery; repeatable") + parser.add_argument("--interval", type=float, default=60, help="seconds between local checks, minimum 1") + parser.add_argument("--iterations", type=int, default=1, help="number of checks, 0 repeats until interrupted") + args = parser.parse_args() + if args.ack_alert: + if not args.state or any((args.manifest, args.origin, args.token, args.transcript, + args.trust, args.audience, args.replay_state, args.output)): + parser.error("--ack-alert requires --state and no monitoring inputs or output") + try: + count = acknowledge_alerts(args.state, args.ack_alert) + except (OSError, ValueError, TypeError, KeyError) as exc: + parser.exit(2, "alert acknowledgment failed: " + str(exc) + "\n") + print(json.dumps({"acknowledged": count})) + return 0 + if not 1 <= args.interval <= 86400 or args.iterations < 0: + parser.error("interval must be 1..86400 seconds and iterations must be nonnegative") + if bool(args.manifest) == bool(args.origin) or (args.origin and not args.subject): + parser.error("provide either a manifest file or --origin with --subject") + inputs = (args.token, args.transcript, args.trust, args.audience, args.replay_state) + if any(inputs) and not all(inputs): + parser.error("token, transcript, trust, audience and replay-state are all required together") + evidence = {"token": args.token, "transcript": args.transcript, "trust": args.trust, + "audience": args.audience, "replay_store": ReplayStore(args.replay_state)} if all(inputs) else None + measurement_cache = {} + discovery = DiscoveryClient([args.origin]) if args.origin else None + try: + schema = json.loads(args.schema.read_bytes()) + except (OSError, ValueError) as exc: + parser.exit(2, "schema could not be loaded: " + str(exc) + "\n") + count = 0 + exit_code = 0 + while args.iterations == 0 or count < args.iterations: + report = run_once(args.manifest, schema, args.state, evidence=evidence, + measurement_cache=measurement_cache, discovery=discovery, + origin=args.origin, expected_subject=args.subject) + output = json.dumps(report, indent=2) + "\n" + if args.output: + args.output.write_text(output) + else: + print(output, end="", flush=True) + exit_code = max(exit_code, int(report["assessment"]["status"] != "unverified")) + count += 1 + if args.iterations == 0 or count < args.iterations: + time.sleep(args.interval) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/observatory_ingestion.py b/tools/observatory_ingestion.py new file mode 100644 index 0000000..607703d --- /dev/null +++ b/tools/observatory_ingestion.py @@ -0,0 +1,74 @@ +"""Authenticate and validate bounded Observatory evidence without network access. + +A signed measurement authenticates the collector, not the truth of its report. +Existing unsigned Observatory database rows deliberately cannot use this profile. +""" +import datetime as dt +import hashlib +import ipaddress +from urllib.parse import urlsplit +from tools.attestation import verify_envelope +from tools.openssl_observation import parse_openssl_observation, MAX_TRANSCRIPT_BYTES, HYBRID_GROUPS, CLASSICAL_GROUPS +from tools.tls_probe import validate_endpoint + + +def ingest_measurement(token, transcript, trust_config, expected_subject, *, expected_audience, + replay_store, now=None, max_age_seconds=86400, expected_snapshot=None): + now = now or dt.datetime.now(dt.timezone.utc) + if type(transcript) is not bytes or len(transcript) > MAX_TRANSCRIPT_BYTES: + return {'accepted': False, 'reason': 'invalid-transcript'} + if type(max_age_seconds) is not int or not 0 < max_age_seconds <= 86400: + return {'accepted': False, 'reason': 'invalid-freshness-policy'} + verified = verify_envelope(token, trust_config, expected_subject, now=now, + expected_audience=expected_audience, replay_store=replay_store, + expected_snapshot=expected_snapshot) + if not verified['verified']: + return {'accepted': False, 'reason': verified['reason']} + claims = verified['claims'] + try: + observation = claims['observation'] + if not isinstance(observation, dict) or set(observation) != {'profile', 'observedAt', 'endpoint', 'probe', 'transcriptSha256'}: + raise ValueError('invalid-observation') + if observation['profile'] != 'openssl-tls13-v1': + raise ValueError('unsupported-observation-profile') + endpoint, probe = observation['endpoint'], observation['probe'] + if set(endpoint) != {'host', 'port', 'address'} or set(probe) != {'name', 'version', 'groups', 'certificateValidation', 'exitCode'}: + raise ValueError('invalid-provenance') + validate_endpoint(endpoint['host'], endpoint['port']) + ipaddress.ip_address(endpoint['address']) + subject = urlsplit(expected_subject) + # This collector observes TLS endpoints, not repositories, URL paths or identities. + if (subject.scheme != 'https' or subject.username is not None or subject.password is not None or subject.query or subject.fragment or + subject.path not in ('', '/') or subject.hostname != endpoint['host'].lower() or (subject.port or 443) != endpoint['port']): + raise ValueError('endpoint-subject-mismatch') + observed = dt.datetime.fromisoformat(observation['observedAt'].replace('Z', '+00:00')) + if observed.tzinfo is None or not dt.timedelta(0) <= now-observed <= dt.timedelta(seconds=max_age_seconds): + raise ValueError('stale-or-future-measurement') + if observed.timestamp() > claims['iat']: + raise ValueError('measurement-after-issuance') + if observation['transcriptSha256'] != hashlib.sha256(transcript).hexdigest(): + raise ValueError('transcript-digest-mismatch') + groups = probe['groups'] + if (probe['name'] != 'openssl' or not isinstance(probe['version'], str) or + not probe['version'].startswith('OpenSSL ') or len(probe['version']) > 256 or + type(probe['exitCode']) is not int or probe['exitCode'] != 0 or + probe['certificateValidation'] != 'pkix-hostname' or not isinstance(groups, list) or + not 1 <= len(groups) <= 16 or any(g not in HYBRID_GROUPS | CLASSICAL_GROUPS for g in groups)): + raise ValueError('invalid-probe-configuration') + parsed = parse_openssl_observation(transcript.decode('utf-8')) + if parsed['status'] != 'observed' or parsed['negotiatedGroup'] not in groups or not parsed['peerSignatureType']: + raise ValueError('unverified-connection') + return {'accepted': True, 'reason': 'authenticated-observation', + 'subject': expected_subject, 'issuer': claims['iss'], 'measurementId': claims['jti'], + 'observedAt': observation['observedAt'], 'expiresAt': claims['exp'], + 'validUntil': min(claims['exp'], + claims['iat'] + trust_config['issuers'][claims['iss']].get('maxAgeSeconds', 3600), + observed.timestamp() + max_age_seconds), + 'provenance': observation, 'observation': parsed, + 'issuerAuthenticated': True, 'independentlyVerified': False, + 'authenticationPqcVerified': False, + 'limitation': 'Trusted collector statement; no independent runtime verification, fleet coverage, or PQC authentication established'} + except (ValueError, TypeError, KeyError, AttributeError, OverflowError): + # A valid JWT is consumed even when its measurement is rejected. Producers + # must issue a new jti for corrected evidence, never replay a rejected token. + return {'accepted': False, 'reason': 'invalid-measurement'} diff --git a/tools/openssl_observation.py b/tools/openssl_observation.py new file mode 100644 index 0000000..54d8b1a --- /dev/null +++ b/tools/openssl_observation.py @@ -0,0 +1,42 @@ +"""Parse OpenSSL summaries as observations, never as authenticated evidence. + +Only the live collector can establish how OpenSSL was invoked. Imported text may +be fabricated; certificateVerified here describes the transcript's claim only. +""" +import re + +HYBRID_GROUPS = frozenset({'X25519MLKEM768', 'SecP256r1MLKEM768', 'SecP384r1MLKEM1024'}) +CLASSICAL_GROUPS = frozenset({'X25519', 'X448', 'x448', 'prime256v1', 'secp256r1', 'secp384r1', 'secp521r1'}) +MAX_TRANSCRIPT_BYTES = 65536 + + +def _one(pattern, output): + values = re.findall(pattern, output, re.MULTILINE | re.IGNORECASE) + return values[0].strip() if len(values) == 1 else None + + +def parse_openssl_observation(output): + if not isinstance(output, str) or len(output.encode('utf-8')) > MAX_TRANSCRIPT_BYTES: + raise ValueError('invalid probe output') + group = _one(r'^\s*(?:Negotiated TLS1\.3 group|Server Temp Key|Peer Temp Key):\s*([^\r\n,]+)', output) + # ECDH is a key type, not a negotiated NamedGroup identifier. + protocol = _one(r'^\s*Protocol(?: version)?\s*:\s*(\S+)\s*$', output) + if protocol is None: + protocol = _one(r'^New,\s*(TLSv[\d.]+), Cipher is \S+\s*$', output) + cipher = _one(r'^\s*(?:Ciphersuite|Cipher)\s*:\s*(\S+)\s*$', output) + if cipher is None: + cipher = _one(r'^New,\s*TLSv[\d.]+, Cipher is (\S+)\s*$', output) + verify = re.findall(r'^\s*Verify return code:\s*(\d+)[^\r\n]*$', output, re.M) + brief_verify = re.findall(r'^Verification: OK\s*$', output, re.M) + certificate_valid = ((len(verify) == 1 and verify[0] == '0') or + (not verify and len(brief_verify) == 1)) + failure = bool(re.search(r'verification error|verify error|alert |handshake failure|no peer certificate', output, re.I)) + completed = protocol == 'TLSv1.3' and cipher is not None and cipher not in ('0000', '(NONE)') and not failure + certificate_valid = certificate_valid and not failure + signature = _one(r'^\s*(?:Peer signature type|Signature type):\s*([^\r\n]+)', output) + return {'tls13Observed': completed, 'protocol': protocol, 'cipher': cipher, + 'negotiatedGroup': group, 'hybridKexObserved': bool(completed and group in HYBRID_GROUPS), + 'certificateVerified': certificate_valid, 'peerSignatureType': signature, + 'authenticationPqcVerified': False, + 'status': 'observed' if completed and group and certificate_valid else 'unknown', + 'note': 'Transcript claims require authenticated provenance; this connection does not establish fleet posture or PQC authentication'} diff --git a/tools/qscan.py b/tools/qscan.py new file mode 100644 index 0000000..4edddb9 --- /dev/null +++ b/tools/qscan.py @@ -0,0 +1,89 @@ +"""Offline adapter for qScan v1 manifests. Never upgrades static evidence to observation.""" +import argparse +import copy +import hashlib +import json +from pathlib import Path +from jsonschema import Draft202012Validator, FormatChecker, ValidationError +from tools.discovery import strict_json +from tools.verifier import parse_time + +ROOT = Path(__file__).resolve().parents[1] +QSCAN_SOURCE_COMMIT = '6a81b5537eaedf61f259334b4e430e580c35942c' + + +def encode_manifest(manifest): + """Deterministic JSON for fixed inputs, not a signature canonicalization scheme.""" + return (json.dumps(manifest, sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(',', ':')) + '\n').encode('utf-8') + + +def convert_qscan(raw, *, repository, commit, profiles, expires_at): + """Require operator identity/profile inputs; never infer runtime from source. + + Keep the exact original bytes as qscan-v1.json beside the result. The evidence + digest covers those bytes, including qScan version, counts and policy. Binding + checks catch accidental mismatches; unsigned source data is not authenticated. + """ + if not isinstance(raw, bytes) or len(raw) > 1048576: + raise ValueError('qScan input must be bytes, at most 1 MiB') + source = strict_json(raw) + schema = json.loads((ROOT / 'schema/crypto-agility.schema.json').read_text()) + Draft202012Validator(schema, format_checker=FormatChecker()).validate(source) + if source['generator']['name'] != 'qScan': + raise ValueError('qScan generator required') + if not repository or not commit or source['subject']['repository'] != repository or source['subject']['commit'] != commit: + raise ValueError('qScan repository/commit binding mismatch or absent') + if parse_time(expires_at) <= parse_time(source['generatedAt']): + raise ValueError('expiry must follow source generation') + subject = {'kind': 'repository', 'id': repository, 'commit': commit} + eid = 'qscan-source' + observed_at = source['generatedAt'] + observations = [] + families = source['cbomSummary']['algorithmFamilies'] + if len({f['family'] for f in families}) != len(families): + raise ValueError('duplicate algorithm family') + for i, family in enumerate(sorted(families, key=lambda f: f['family'])): + if family['count'] <= 0: + continue + observations.append({'id': f'qscan-family-{i}', 'capability': 'source-uses:' + family['family'], + 'value': 'supported', 'method': 'inferred', 'observedAt': observed_at, 'evidenceIds': [eid]}) + hybrid = source['posture']['hybridKexInUse'] + observations.append({'id': 'qscan-hybrid-declaration', 'capability': 'hybrid-key-establishment', + 'value': 'unknown' if hybrid is None else ('supported' if hybrid else 'unsupported'), + 'method': 'declared', 'observedAt': observed_at, 'evidenceIds': [eid]}) + output = {'version': 2, 'manifestType': 'crypto-agility', 'generatedAt': observed_at, + 'expiresAt': expires_at, 'subject': subject, 'observations': observations, + 'profiles': sorted(copy.deepcopy(profiles), key=lambda p: p['id']), 'migration': [], + 'evidence': [{'id': eid, 'subjectId': repository, 'mediaType': 'application/json', + 'uri': 'file:qscan-v1.json', 'digest': {'algorithm': 'sha-256', 'value': hashlib.sha256(raw).hexdigest()}}]} + schema = json.loads((ROOT / 'schema/crypto-agility-v2.schema.json').read_text()) + Draft202012Validator(schema, format_checker=FormatChecker()).validate(output) + if len({p['id'] for p in profiles}) != len(profiles): + raise ValueError('duplicate profile ID') + # Supplied profiles are declarations and cannot smuggle references to other + # evidence. Runtime observations belong in the authenticated ingestion path. + if any(p.get('evidenceIds') for p in profiles): + raise ValueError('qScan profiles cannot claim runtime evidence') + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source', type=Path) + parser.add_argument('--repository', required=True) + parser.add_argument('--commit', required=True) + parser.add_argument('--profiles', required=True, type=Path) + parser.add_argument('--expires-at', required=True) + args = parser.parse_args() + if args.source.stat().st_size > 1048576 or args.profiles.stat().st_size > 1048576: + parser.error('input size limit') + try: + output = convert_qscan(args.source.read_bytes(), repository=args.repository, commit=args.commit, + profiles=strict_json(args.profiles.read_bytes()), expires_at=args.expires_at) + print(encode_manifest(output).decode('utf-8'), end='') + except (ValueError, TypeError, KeyError, OSError, ValidationError) as exc: + parser.exit(1, str(exc) + '\n') + + +if __name__ == '__main__': + main() diff --git a/tools/render_draft.py b/tools/render_draft.py new file mode 100644 index 0000000..4a829e8 --- /dev/null +++ b/tools/render_draft.py @@ -0,0 +1,53 @@ +"""Generate readable Markdown from the authoritative RFCXML source.""" +import argparse +from pathlib import Path +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +XML = ROOT / 'draft/draft-acosta-crypto-agility-manifest-02.xml' +MARKDOWN = XML.with_suffix('.md') + + +def inline(element): + text = element.text or '' + for child in element: + text += '[' + child.attrib['target'] + ']' if child.tag == 'xref' else inline(child) + text += child.tail or '' + return text.strip() + + +def render(path=XML): + root = ET.parse(path).getroot() + lines = ['# ' + root.findtext('front/title'), '', + '`' + root.attrib['docName'] + '` | individual Informational proposal | not submitted', '', + 'Author: Leon Acosta, QuantaKrypto, leon@quantakrypto.com', '', + '', '', + '## Abstract', '', root.findtext('front/abstract/t'), ''] + for section in list(root.findall('middle/section')) + list(root.findall('back/section')): + lines += ['## ' + section.findtext('name'), ''] + for element in section: + if element.tag == 't': + lines += [inline(element), ''] + elif element.tag == 'sourcecode': + lines += ['```' + element.attrib.get('type', ''), element.text or '', '```', ''] + for references in root.findall('back/references'): + lines += ['## ' + references.findtext('name'), ''] + for ref in references.findall('reference'): + lines += ['- [' + ref.attrib['anchor'] + '] [' + ref.findtext('front/title') + '](' + ref.attrib['target'] + ')'] + lines += [''] + return '\n'.join(lines) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + expected = render() + if args.check: + return 0 if MARKDOWN.read_text() == expected else 1 + MARKDOWN.write_text(expected) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/state.py b/tools/state.py new file mode 100644 index 0000000..c3f8b53 --- /dev/null +++ b/tools/state.py @@ -0,0 +1,216 @@ +"""Bounded owner-only local state, serialized writers and durable atomic replacement. + +The operator must protect the containing directory and backups. This does not +protect against the account owner, root, disk rollback, or state deletion. +POSIX only: use a service supervisor to schedule the monitor and route its alerts. +""" +import contextlib +import datetime as dt +import fcntl +import hashlib +import json +import os +from pathlib import Path +import stat +import uuid + +MAX_STATE_BYTES = 4 * 1024 * 1024 + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def fingerprint(report): + return hashlib.sha256(canonical(report)).hexdigest() + + +def _owned_regular(fd): + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_nlink != 1: + raise ValueError("state must be an owner-controlled regular file") + if info.st_mode & 0o077: + raise ValueError("state permissions must be 0600") + + +@contextlib.contextmanager +def locked_document(path): + """Yield (previous, save), holding a separate lock across read/replace/fsync.""" + path = Path(path).absolute() + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + lock = None + try: + info = os.fstat(directory) + if info.st_uid != os.geteuid() or info.st_mode & 0o022: + raise ValueError("state directory must be owned and not writable by others") + try: + lock = os.open(path.name + ".lock", os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + except FileExistsError: + # Separate creation from opening an existing lock: concurrent openat + # with O_CREAT|O_NOFOLLOW can spuriously return ENOENT on macOS. + lock = os.open(path.name + ".lock", os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory) + _owned_regular(lock) + fcntl.flock(lock, fcntl.LOCK_EX) + try: + fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + except FileNotFoundError: + previous = None + else: + with os.fdopen(fd, "rb") as source: + _owned_regular(source.fileno()) + raw = source.read(MAX_STATE_BYTES + 1) + if len(raw) > MAX_STATE_BYTES: + raise ValueError("state exceeds size limit") + previous = json.loads(raw) + if not isinstance(previous, dict): + raise ValueError("state must be an object") + + def save(value): + raw = canonical(value) + b"\n" + if len(raw) > MAX_STATE_BYTES: + raise ValueError("state exceeds size limit") + name = "." + path.name + "." + uuid.uuid4().hex + ".tmp" + fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + try: + with os.fdopen(fd, "wb") as target: + target.write(raw) + target.flush() + os.fsync(target.fileno()) + os.replace(name, path.name, src_dir_fd=directory, dst_dir_fd=directory) + os.fsync(directory) + finally: + try: + os.unlink(name, dir_fd=directory) + except FileNotFoundError: + pass + yield previous, save + finally: + if lock is not None: + os.close(lock) + os.close(directory) + + +def _alert(kind, **details): + action = { + "new-finding": "Review the referenced evidence and refresh or correct the manifest.", + "compatibility-regression": "Check both endpoint configurations and negotiate a fresh TLS probe.", + "coverage-lost": "Restore the missing endpoint profile or record its retirement explicitly.", + "verification-regression": "Refresh authenticated evidence; do not rely on the previous assurance.", + "tls-regression": "Investigate the collector evidence and negotiate a fresh authenticated TLS probe.", + }[kind] + result = {"type": kind, "severity": "warning", "action": action, **details} + result["id"] = fingerprint(result) + return result + + +def compare(previous, current): + old = {(f["code"], f.get("ref", "")) for f in previous.get("assessment", {}).get("findings", [])} + new = {(f["code"], f.get("ref", "")) for f in current.get("assessment", {}).get("findings", [])} + prev_pairs = {(p["client"], p["server"]): p["assessment"]["status"] for p in previous.get("pairs", [])} + curr_pairs = {(p["client"], p["server"]): p["assessment"]["status"] for p in current.get("pairs", [])} + alerts = [_alert("new-finding", code=c, ref=r) for c, r in sorted(new - old)] + for (client, server), status in sorted(curr_pairs.items()): + prior = prev_pairs.get((client, server)) + if prior is not None and prior != status and status in ("incompatible", "unknown"): + alerts.append(_alert("compatibility-regression", client=client, server=server, + **{"from": prior, "to": status})) + for client, server in sorted(prev_pairs.keys() - curr_pairs.keys()): + alerts.append(_alert("coverage-lost", client=client, server=server)) + for profile in sorted(set(previous.get("profileIds", [])) - set(current.get("profileIds", []))): + alerts.append(_alert("coverage-lost", profile=profile)) + old_measurements = {(m["issuer"], m["subject"]): m for m in previous.get("measurements", [])} + new_measurements = {(m["issuer"], m["subject"]): m for m in current.get("measurements", [])} + for key, prior in sorted(old_measurements.items()): + latest = new_measurements.get(key) + if latest is None: + alerts.append(_alert("verification-regression", issuer=key[0], subject=key[1], reason="measurement-missing")) + elif prior["status"] != "stale" and latest["status"] == "stale": + alerts.append(_alert("verification-regression", issuer=key[0], subject=key[1], reason="measurement-expired")) + # Historical authenticated evidence is a regression baseline, never current assurance. + trusted = previous.get("lastAuthenticatedMeasurements", previous.get("measurements", [])) + for prior in trusted: + key = (prior["issuer"], prior["subject"]) + latest = new_measurements.get(key) + if latest and latest["status"] == "authenticated-observation" and prior.get("hybridKexObserved") and not latest.get("hybridKexObserved"): + alerts.append(_alert("tls-regression", issuer=key[0], subject=key[1], + **{"from": prior.get("negotiatedGroup"), "to": latest.get("negotiatedGroup")})) + old_status = previous.get("assessment", {}).get("status") + new_status = current.get("assessment", {}).get("status") + if old_status == "verified" and new_status != old_status: + alerts.append(_alert("verification-regression", **{"from": old_status, "to": new_status})) + return {"alerts": alerts, "resolved": [{"code": c, "ref": r} for c, r in sorted(old - new)]} + + +def _time(value): + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("state timestamp requires timezone") + return parsed + + +def update_state(path, report): + if not isinstance(report.get("subject"), str) or not report["subject"]: + raise ValueError("state subject required") + if report.get("assessment", {}).get("status") == "invalid": + raise ValueError("invalid assessments cannot replace monitoring state") + with locked_document(path) as (previous, save): + if previous is not None and not previous.get("noBaseline", False): + if previous.get("subject") != report["subject"]: + raise ValueError("state subject mismatch") + for field in ("checkedAt", "manifestGeneratedAt"): + if previous.get(field) and (not report.get(field) or _time(report[field]) < _time(previous[field])): + raise ValueError("state rollback: " + field) + delta = compare(previous, report) + else: + delta = compare({}, report) + delta["initialBaseline"] = True + pending = {a["id"]: a for a in (previous or {}).get("pendingAlerts", [])} + for alert in delta["alerts"]: + if alert["id"] not in pending: + pending[alert["id"]] = {**alert, "deliveryId": uuid.uuid4().hex} + # Save alerts with the baseline before output or external delivery. Retry the + # same deliveryId until the supervisor explicitly acknowledges delivery. + historical = (previous or {}).get("lastAuthenticatedMeasurements", (previous or {}).get("measurements", [])) + trusted = {(m["issuer"], m["subject"]): m for m in historical} + for measurement in report.get("measurements", []): + if measurement["status"] == "authenticated-observation": + trusted[(measurement["issuer"], measurement["subject"])] = measurement + saved = {**report, "pendingAlerts": list(pending.values()), + "lastAuthenticatedMeasurements": list(trusted.values())} + save(saved) + delta["alerts"] = saved["pendingAlerts"] + return delta + + +def acknowledge_alerts(path, delivery_ids): + """Call only after confirmed delivery; old acknowledgments cannot clear recurrence.""" + if (not isinstance(delivery_ids, (list, tuple)) or not delivery_ids + or any(not isinstance(value, str) or len(value) != 32 + or any(c not in "0123456789abcdef" for c in value) for value in delivery_ids)): + raise ValueError("delivery IDs must be 32 lowercase hexadecimal characters") + with locked_document(path) as (previous, save): + if previous is None: + raise ValueError("monitor state does not exist") + ids = set(delivery_ids) + pending = previous.get("pendingAlerts", []) + remaining = [a for a in pending if a["deliveryId"] not in ids] + previous["pendingAlerts"] = remaining + save(previous) + return len(pending) - len(remaining) + + +def enqueue_failure(path, alert): + """Durably queue an input failure without advancing a successful baseline.""" + with locked_document(path) as (previous, save): + state = previous if previous is not None else {"noBaseline": True} + pending = {a["id"]: a for a in state.get("pendingAlerts", [])} + if alert["id"] not in pending: + pending[alert["id"]] = {**alert, "deliveryId": uuid.uuid4().hex} + state["pendingAlerts"] = list(pending.values()) + save(state) + # Lead with the current failure, followed by any other undelivered alerts. + return [pending[alert["id"]]] + [a for k, a in pending.items() if k != alert["id"]] diff --git a/tools/tls_probe.py b/tools/tls_probe.py new file mode 100644 index 0000000..7fb138d --- /dev/null +++ b/tools/tls_probe.py @@ -0,0 +1,154 @@ +"""Bounded, authenticated TLS 1.3 measurement of explicitly authorized endpoints. + +OpenSSL performs PKIX chain and DNS/IP identity validation and TLS negotiation. +No application request, session resumption, custom combiner or automatic downgrade. +""" +import argparse +import datetime as dt +import hashlib +import ipaddress +import json +import math +import os +import re +import selectors +import socket +import ssl +import subprocess +import time +try: + from tools.openssl_observation import parse_openssl_observation, HYBRID_GROUPS, CLASSICAL_GROUPS, MAX_TRANSCRIPT_BYTES +except ModuleNotFoundError: + from openssl_observation import parse_openssl_observation, HYBRID_GROUPS, CLASSICAL_GROUPS, MAX_TRANSCRIPT_BYTES + +DEFAULT_GROUPS = ('X25519MLKEM768', 'X25519', 'secp256r1') + + +def validate_endpoint(host, port=443, timeout=5): + if (not isinstance(host, str) or not host or len(host) > 253 or + type(port) is not int or not 1 <= port <= 65535 or + type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0 < timeout <= 30): + raise ValueError('invalid probe parameters') + try: + ipaddress.ip_address(host) + except ValueError: + if not all(re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?', x) for x in host.split('.')): + raise ValueError('host must be an ASCII DNS name or IP literal') + + +def _run_bounded(args, timeout, max_bytes=MAX_TRANSCRIPT_BYTES, *, diagnostics_only=False): + """Read a bounded pipe incrementally; never shell out or buffer unlimited output.""" + with subprocess.Popen(args, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL if diagnostics_only else subprocess.PIPE, + stderr=subprocess.PIPE if diagnostics_only else subprocess.STDOUT, + env={**os.environ, 'LC_ALL': 'C'}) as proc: + selector = selectors.DefaultSelector() + selector.register(proc.stderr if diagnostics_only else proc.stdout, selectors.EVENT_READ) + output = bytearray() + deadline = time.monotonic() + timeout + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError('OpenSSL timeout') + for key, _ in selector.select(remaining): + chunk = os.read(key.fd, min(4096, max_bytes + 1 - len(output))) + if not chunk: + selector.unregister(key.fileobj) + break + output.extend(chunk) + if len(output) > max_bytes: + raise ValueError('OpenSSL output limit exceeded') + proc.wait(timeout=max(0.001, deadline-time.monotonic())) + return proc.returncode, bytes(output) + finally: + selector.close() + if proc.poll() is None: + proc.kill() + proc.wait() + + +def probe_openssl(host, port=443, timeout=5, *, groups=DEFAULT_GROUPS, + cafile=None, address=None, openssl='openssl', allow_private=False): + """Use a pinned address; private networks require explicit local authorization. + + The caller must authorize the endpoint independently of a downloaded manifest. + An unsupported hybrid group fails; it never silently retries classical groups. + """ + validate_endpoint(host, port, timeout) + if not isinstance(groups, (list, tuple)) or not groups or len(groups) > 16 or any(not isinstance(g, str) or g not in HYBRID_GROUPS | CLASSICAL_GROUPS for g in groups): + raise ValueError('unrecognized TLS group') + # Explicit address avoids resolver timeouts and records the actual probed endpoint. + if address is None: + raise ValueError('explicit resolved address required for bounded probe') + ip = ipaddress.ip_address(address) + if not allow_private and not ip.is_global: + raise ValueError('non-public address requires explicit authorization') + target = f'[{ip}]:{port}' if ip.version == 6 else f'{ip}:{port}' + args = [openssl, 's_client', '-connect', target, '-servername', host, '-tls1_3', + '-verify_return_error', '-brief', '-groups', ':'.join(groups)] + try: + ipaddress.ip_address(host) + args += ['-verify_ip', host] + except ValueError: + args += ['-verify_hostname', host] + if cafile: + args += ['-CAfile', os.fspath(cafile)] + version_code, version_bytes = _run_bounded([openssl, 'version'], timeout, 4096) + if version_code: + raise ValueError('cannot identify OpenSSL') + code, raw = _run_bounded(args, timeout, diagnostics_only=True) + parsed = parse_openssl_observation(raw.decode('utf-8', errors='strict')) + verified = (code == 0 and parsed['status'] == 'observed' and + parsed['negotiatedGroup'] in groups and parsed['peerSignatureType'] is not None) + hybrid = verified and parsed['hybridKexObserved'] + classical = verified and parsed['negotiatedGroup'] in CLASSICAL_GROUPS + observed_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec='seconds') + return {**parsed, 'host': host, 'port': port, 'address': str(ip), 'observedAt': observed_at, + 'status': 'verified-connection' if verified else 'failed', + 'certificateValidated': verified, 'hybridKexVerified': hybrid, + 'classicalFallbackObserved': bool(classical and any(g in HYBRID_GROUPS for g in groups)), + 'authentication': 'certificate-and-handshake-validated' if verified else 'unknown', + 'pqcVerified': False, 'independentlyVerified': False, + 'evidence': {'profile': 'openssl-tls13-v1', 'observedAt': observed_at, + 'endpoint': {'host': host, 'port': port, 'address': str(ip)}, + 'probe': {'name': 'openssl', 'version': version_bytes.decode().strip(), + 'groups': list(groups), 'certificateValidation': 'pkix-hostname', 'exitCode': code}, + 'transcriptSha256': hashlib.sha256(raw).hexdigest()}, + 'transcript': raw.decode(), + 'note': 'Verified connection is scoped to one address/time/configuration; PQC authentication and independent corroboration remain unverified'} + + +def probe(host, port=443, timeout=5): + """Legacy system-ssl probe; cipher suites cannot identify hybrid key exchange.""" + validate_endpoint(host, port, timeout) + context = ssl.create_default_context() + context.minimum_version = context.maximum_version = ssl.TLSVersion.TLSv1_3 + with socket.create_connection((host, port), timeout=timeout) as raw: + with context.wrap_socket(raw, server_hostname=host) as connection: + return {'host': host, 'port': port, 'protocol': connection.version(), + 'cipher': connection.cipher()[0], 'certificateValidated': True, + 'keyEstablishment': 'unknown', 'authentication': 'unknown', 'pqcVerified': False, + 'note': 'TLS cipher suite does not identify the negotiated KEM or certificate signature'} + + +def main(): + p = argparse.ArgumentParser() + p.add_argument('host') + p.add_argument('--port', type=int, default=443) + p.add_argument('--timeout', type=float, default=5) + p.add_argument('--groups', default=':'.join(DEFAULT_GROUPS)) + p.add_argument('--cafile') + p.add_argument('--address', required=True, help='explicit authorized IP address, resolved by operator') + p.add_argument('--allow-private', action='store_true') + a = p.parse_args() + try: + result = probe_openssl(a.host, a.port, a.timeout, groups=a.groups.split(':'), + cafile=a.cafile, address=a.address, allow_private=a.allow_private) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + result = {'status': 'failed', 'reason': str(exc), 'pqcVerified': False} + print(json.dumps(result, indent=2)) + return 0 if result.get('certificateValidated') else 1 + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/verifier.py b/tools/verifier.py new file mode 100644 index 0000000..690d009 --- /dev/null +++ b/tools/verifier.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Bounded offline structural/semantic assessment, never a trust assertion.""" +import argparse +import datetime as dt +import json +import re +from pathlib import Path +from urllib.parse import urlsplit +from jsonschema import Draft202012Validator, FormatChecker + +MAX_BYTES = 1048576 +UTC_TIME = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[0-5]\d(?:\.\d{1,6})?Z$') + + +def parse_time(value): + if not isinstance(value, str) or not UTC_TIME.fullmatch(value): + raise ValueError('UTC RFC3339 timestamp required (no leap seconds)') + return dt.datetime.fromisoformat(value.replace('Z', '+00:00')) + + +def load_manifest(data): + """Reject duplicate keys, non-JSON numbers, excess bytes/depth before validation.""" + if isinstance(data, bytes): + if len(data) > MAX_BYTES: + raise ValueError('manifest too large') + data = data.decode('utf-8') + if not isinstance(data, str) or len(data.encode('utf-8')) > MAX_BYTES: + raise ValueError('manifest too large or invalid') + # Enforce nesting limit before json.loads consumes stack/memory. + depth = 0 + quoted = escaped = False + for char in data: + if quoted: + if escaped: + escaped = False + elif char == '\\': + escaped = True + elif char == '"': + quoted = False + elif char == '"': + quoted = True + elif char in '[{': + depth += 1 + if depth > 16: + raise ValueError('manifest nesting exceeds 16') + elif char in ']}': + depth -= 1 + def pairs(items): + value = {} + for key, item in items: + if key in value: + raise ValueError('duplicate JSON member') + value[key] = item + return value + def invalid_number(_): + raise ValueError('non-JSON number') + return json.loads(data, object_pairs_hook=pairs, parse_constant=invalid_number) + + +def assess(m, schema, now=None, max_observation_age=86400, expected_subject=None): + now = now or dt.datetime.now(dt.timezone.utc) + if now.tzinfo is None or not 0 < max_observation_age <= 31536000: + raise ValueError('invalid local time or observation freshness policy') + checker = FormatChecker() + @checker.checks('date-time', raises=(ValueError, TypeError)) + def calendar_timestamp(value): + if not isinstance(value, str): + return True + parse_time(value) + return True + errors = list(Draft202012Validator(schema, format_checker=checker).iter_errors(m)) + if errors: + return {'status': 'invalid', 'findings': [{'code': 'schema', 'message': e.message[:512]} for e in errors[:100]]} + findings = [] + def add(code, ref): + findings.append({'code': code, 'ref': ref}) + if expected_subject is not None and m['subject']['id'] != expected_subject: + add('subject-mismatch', 'manifest') + if m['subject']['kind'] == 'origin': + subject = m['subject']['id'] + try: + origin = urlsplit(subject) + if (not subject.isascii() or origin.scheme != 'https' or not origin.hostname + or origin.username is not None or origin.password is not None + or origin.path or origin.query or origin.fragment + or origin.port is not None or subject != 'https://' + origin.hostname + or not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', origin.hostname)): + add('invalid-origin', 'manifest') + except ValueError: + add('invalid-origin', 'manifest') + generated = parse_time(m['generatedAt']) + expires = parse_time(m['expiresAt']) + if generated > now + dt.timedelta(minutes=5): + add('future-manifest', 'manifest') + if expires <= now or expires <= generated: + add('expired', 'manifest') + evidence = {x['id']: x for x in m.get('evidence', [])} + profiles = {x['id']: x for x in m['profiles']} + for group in ('observations', 'migration', 'profiles', 'evidence', 'policies'): + items = m.get(group, []) + if len(items) != len({x['id'] for x in items}): + add('duplicate-id', group) + for e in evidence.values(): + if e['subjectId'] != m['subject']['id']: + add('subject-mismatch', e['id']) + by_capability = {} + for o in m['observations']: + if o['method'] == 'observed' and not o.get('evidenceIds'): + add('unsubstantiated-observation', o['id']) + observed = parse_time(o['observedAt']) + if observed > now + dt.timedelta(minutes=5) or observed > generated + dt.timedelta(minutes=5): + add('future-observation', o['id']) + if (now - observed).total_seconds() > max_observation_age: + add('stale-observation', o['id']) + by_capability.setdefault(o['capability'], set()).add(o['value']) + for ref in o.get('evidenceIds', []): + if ref not in evidence: + add('missing-evidence', ref) + for capability, values in by_capability.items(): + if {'supported', 'unsupported'} <= values: + add('conflicting-observations', capability) + for p in m['profiles']: + if p['fallback'] != 'deny-classical': + add('fallback-risk', p['id']) + for ref in p.get('evidenceIds', []): + if ref not in evidence: + add('missing-evidence', ref) + for item in m['migration']: + if item['status'] in ('validated', 'deployed') and not item.get('evidenceIds'): + add('unsubstantiated-deployment', item['id']) + for ref in item.get('profileIds', []): + if ref not in profiles: + add('missing-profile', ref) + for ref in item.get('evidenceIds', []): + if ref not in evidence: + add('missing-evidence', ref) + for exception in m.get('exceptions', []): + if parse_time(exception['expiresAt']) <= now: + add('expired-exception', exception['findingId']) + if not exception.get('owner'): + add('unowned-exception', exception['findingId']) + return {'status': 'needs-review' if findings else 'unverified', 'findings': findings, + 'limitations': 'No signatures, evidence truth, live negotiation or deployment verified; policies and exceptions never suppress findings'} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('manifest', type=Path) + parser.add_argument('--schema', type=Path, default=Path('schema/crypto-agility-v2.schema.json')) + parser.add_argument('--now') + args = parser.parse_args() + try: + with args.manifest.open('rb') as source: + manifest = load_manifest(source.read(MAX_BYTES + 1)) + schema = json.loads(args.schema.read_text()) + result = assess(manifest, schema, parse_time(args.now) if args.now else None) + except (OSError, ValueError, TypeError, KeyError) as exc: + result = {'status': 'invalid', 'findings': [{'code': 'input', 'message': str(exc)}]} + print(json.dumps(result, indent=2)) + return 0 if result['status'] == 'unverified' else 1 + + +if __name__ == '__main__': + raise SystemExit(main())