From c236a5609e2e6f1cfc56b65dc28bca45ecf77be1 Mon Sep 17 00:00:00 2001 From: Sami Mechkor Date: Sun, 12 Jul 2026 00:05:26 +1000 Subject: [PATCH 1/4] security: harden signing-key lifecycle verification --- .github/workflows/ci.yml | 42 +- CONTRIBUTING.md | 18 +- KNOWN_KEYS.json | 28 +- README.md | 201 ++- SECURITY.md | 100 +- SIGNING_KEY_LIFECYCLE.json | 70 + clients/csharp/AwarenessClient.cs | 67 +- clients/js/README.md | 38 +- clients/js/cli.js | 9 +- clients/js/index.d.ts | 34 +- clients/js/index.js | 214 ++- clients/js/package.json | 53 +- clients/js/tests/anchor.test.js | 131 ++ .../js/tests/fixtures/answer_anchored.json | 1 + clients/js/tests/fixtures/answer_pubkey.txt | 1 + .../js/tests/fixtures/awareness_noanchor.json | 1 + .../js/tests/fixtures/awareness_pubkey.txt | 1 + clients/python/.gitignore | 5 + clients/python/README.md | 38 +- clients/python/dynamicfeed_verify/__init__.py | 375 ++++- clients/python/dynamicfeed_verify/__main__.py | 12 +- clients/python/pyproject.toml | 24 +- .../tests/fixtures/answer_anchored.json | 1 + .../python/tests/fixtures/answer_pubkey.txt | 1 + .../tests/fixtures/awareness_noanchor.json | 1 + .../tests/fixtures/awareness_pubkey.txt | 1 + clients/python/tests/test_anchor.py | 190 +++ clients/rust/Cargo.lock | 545 ++++++- clients/rust/Cargo.toml | 12 +- clients/rust/README.md | 168 +- clients/rust/src/lib.rs | 1426 +++++++++++++++-- clients/rust/src/main.rs | 223 ++- clients/rust/tests/conformance.rs | 187 ++- .../rust/tests/fixtures/answer_anchored.json | 1 + clients/rust/tests/fixtures/answer_pubkey.txt | 1 + .../tests/fixtures/awareness_noanchor.json | 1 + .../rust/tests/fixtures/awareness_pubkey.txt | 1 + .../tests/fixtures/signing-key-lifecycle.json | 70 + examples/live-data-integrity/README.md | 22 +- examples/live-data-integrity/report.py | 59 +- examples/live-data-integrity/results.json | 73 +- examples/openai/README.md | 12 +- .../grounding_on_verifiable_live_data.ipynb | 51 +- examples/verified-agent/README.md | 67 +- examples/verified-agent/agent.py | 375 ++++- examples/verified-agent/test_agent.py | 198 +++ reliability/a2a-artifact-vectors.json | 18 +- scripts/check_rotation_policy.py | 81 + tests/vectors/README.md | 13 +- tests/vectors/signed-answer-anchored.json | 4 +- tests/vectors/signed-awareness.json | 9 +- tests/verify_vectors.mjs | 26 +- tests/verify_vectors.py | 36 +- 53 files changed, 4530 insertions(+), 806 deletions(-) create mode 100644 SIGNING_KEY_LIFECYCLE.json create mode 100644 clients/js/tests/anchor.test.js create mode 100644 clients/js/tests/fixtures/answer_anchored.json create mode 100644 clients/js/tests/fixtures/answer_pubkey.txt create mode 100644 clients/js/tests/fixtures/awareness_noanchor.json create mode 100644 clients/js/tests/fixtures/awareness_pubkey.txt create mode 100644 clients/python/.gitignore create mode 100644 clients/python/tests/fixtures/answer_anchored.json create mode 100644 clients/python/tests/fixtures/answer_pubkey.txt create mode 100644 clients/python/tests/fixtures/awareness_noanchor.json create mode 100644 clients/python/tests/fixtures/awareness_pubkey.txt create mode 100644 clients/python/tests/test_anchor.py create mode 100644 clients/rust/tests/fixtures/answer_anchored.json create mode 100644 clients/rust/tests/fixtures/answer_pubkey.txt create mode 100644 clients/rust/tests/fixtures/awareness_noanchor.json create mode 100644 clients/rust/tests/fixtures/awareness_pubkey.txt create mode 100644 clients/rust/tests/fixtures/signing-key-lifecycle.json create mode 100644 examples/verified-agent/test_agent.py create mode 100644 scripts/check_rotation_policy.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccb6456..d157758 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,16 +10,27 @@ permissions: jobs: python: - name: Python verifier + vectors + name: Python ${{ matrix.python }} verifier + lifecycle policy runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.8", "3.12"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.12" - - run: pip install cryptography + python-version: ${{ matrix.python }} + - run: python -m pip install -e clients/python pytest build + - name: lifecycle and anchor regressions + run: python -m pytest clients/python/tests + - name: non-actionable verified-agent regressions + run: python -m unittest examples/verified-agent/test_agent.py - name: conformance vectors run: python3 tests/verify_vectors.py + - name: package build + run: python -m build clients/python + - name: notebook JSON integrity + run: python -m json.tool examples/openai/grounding_on_verifiable_live_data.ipynb >/dev/null - name: reliability self-test run: python3 reliability/verify_reliability.py --selftest @@ -32,8 +43,13 @@ jobs: with: node-version: "20" - run: npm install --prefix clients/js + - name: lifecycle and anchor regressions + run: npm test --prefix clients/js - name: conformance vectors run: node tests/verify_vectors.mjs + - name: package contents + working-directory: clients/js + run: npm pack --dry-run - name: reliability self-test run: node reliability/verify_reliability.js --selftest @@ -49,5 +65,21 @@ jobs: with: components: rustfmt, clippy - run: cargo fmt --check - - run: cargo clippy --all-targets -- -D warnings - - run: cargo test + - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo test --all-features + - name: package and test packaged source + run: | + cargo package --allow-dirty + package_dir="$(mktemp -d)" + tar -xzf target/package/dynamicfeed-verify-1.0.2.crate -C "$package_dir" + cargo test --all-features --manifest-path "$package_dir/dynamicfeed-verify-1.0.2/Cargo.toml" + + rotation-policy: + name: Signing-key rotation policy guardrail + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python scripts/check_rotation_policy.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b81aac..383b3c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,19 @@ # Contributing -Thanks for your interest. The goal of this repo is one thing done well: **independently verifying DF-VERIFY/1 signed responses, byte-for-byte, in any language**, plus the reliability object that grades how much to believe a datapoint. +Thanks for your interest. The goal of this repository is one thing done well: independently check +DF-VERIFY/1 signature bytes and apply caller-authenticated signer-lifecycle policy consistently +across languages, while keeping the separate reliability object honest. ## The bar -Every verifier, in every language, must agree. That agreement is defined by the language-agnostic vectors in [`tests/vectors`](tests/vectors), not by any one implementation: +Every supported reference verifier must agree on signature mathematics and lifecycle policy. The C# +sample is quarantined until it reaches that bar. Agreement is defined by the language-agnostic +vectors and lifecycle regressions, not by any one implementation: - **Canonicalization** must match the expected bytes exactly (keys sorted recursively, compact separators, non-ASCII escaped `\uXXXX` including astral surrogate pairs, numbers verbatim, top-level `signature` stripped). -- The **authentic** signed envelope must verify; the **tampered** twin must be rejected. +- Authentic historical bytes may be cryptographically valid while lifecycle-rejected. Tampered + signed bytes must be cryptographically rejected. Compromised signers must never pass live policy. +- Registry revision floors, explicit historical mode, and active-only live acceptance must match. - For the reliability object, the cases in [`reliability/conformance-vectors.json`](reliability/conformance-vectors.json) must pass (the honesty rules, including `signed != verified`). CI runs all of this on every push and pull request. A change is mergeable when CI is green. @@ -15,9 +21,11 @@ CI runs all of this on every push and pull request. A change is mergeable when C ## Adding or porting a verifier 1. Put it under `clients//`. -2. Reproduce the canonicalization and the Ed25519 check; do not invent a new format. +2. Reproduce strict canonicalization, Ed25519, key-ID binding, lifecycle validation, and caller-held + revision floors; do not invent a new format. 3. Add a test (or harness) that runs the vectors in `tests/vectors`, and wire it into [`.github/workflows/ci.yml`](.github/workflows/ci.yml). -4. Open a PR. Keep it dependency-light and offline-capable: a verifier should need nothing from the issuer at runtime beyond fetching the public key. +4. Open a PR. Keep it dependency-light and offline-capable. A flat public key is crypto-only; + policy-aware offline use requires a reviewed lifecycle registry. ## Running the checks locally diff --git a/KNOWN_KEYS.json b/KNOWN_KEYS.json index 4cd77fc..72f2c2e 100644 --- a/KNOWN_KEYS.json +++ b/KNOWN_KEYS.json @@ -1,7 +1,29 @@ { - "note": "Dynamic Feed Ed25519 signing keys, pinned OUTSIDE dynamicfeed.ai so receipts remain verifiable even if the domain ceases to exist (Principle 1, the courtroom test). key_id = 'df-ed25519-' + sha256(raw_public_key)[:12]. Keys are only ever ADDED here, never removed. Live set: https://dynamicfeed.ai/.well-known/keys", + "schema": "df-known-key-archive/v2", + "deprecated": true, + "superseded_by": "SIGNING_KEY_LIFECYCLE.json", + "note": "Compatibility archive of public key bytes only. This file is deliberately not a flat key map and MUST NOT be used to make a signer-acceptance decision. Validate SIGNING_KEY_LIFECYCLE.json and apply lifecycle policy instead.", "keys": { - "df-ed25519-4cb32e72f333": "O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk=" + "df-ed25519-4cb32e72f333": { + "public_key_base64url": "O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk=", + "status": "compromised", + "compromised_after": "2026-07-11T00:00:00Z", + "policy_accepted": false + }, + "df-ed25519-6ca0de29113b": { + "public_key_base64url": "acMWky59eQfoVqCUgmUM3tcl35YioRngL4wVDIQsstg=", + "status": "active", + "active_from": "2026-07-11T09:49:41Z", + "policy_accepted": false, + "reason": "This deprecated compatibility archive is not lifecycle authority; validate SIGNING_KEY_LIFECYCLE.json." + } }, - "first_published": "2026-07-05" + "policy": { + "flat_key_acceptance_forbidden": true, + "cryptographic_validity_separate_from_lifecycle": true, + "compromised_keys_never_policy_accepted": true, + "historical_public_key_bytes_retained": true + }, + "first_published": "2026-07-05", + "last_reviewed": "2026-07-11" } diff --git a/README.md b/README.md index a4f5256..2ec968c 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,176 @@ -# DF-VERIFY/1: verifiable grounding for AI that acts +# DF-VERIFY/1 verifier sources + +Reference verifier source for Dynamic Feed signed envelopes and signing-key lifecycle policy. +The security boundary is deliberately split: + +- `crypto_valid` means the Ed25519 signature, exact metadata, canonical bytes, and key-ID binding + passed; +- `ok` / policy acceptance additionally requires an acceptable signer in a validated lifecycle + registry whose source the verifier authenticated; +- a signature proves integrity under that key, not objective truth, safety, or legal compliance. + +## Security notice — 2026-07-11 + +Earlier published verifiers accepted a flat public-key map and had no retired/compromised signer +policy. The former signing key `df-ed25519-4cb32e72f333` is now classified **compromised** using the +conservative boundary `2026-07-11T00:00:00Z`. Its public bytes are retained so historical signature +mathematics remain inspectable, but that key must never produce a live policy-accepted result. + +The hardened versions in this repository are release targets, not evidence that package registries +have already been updated: + +| Ecosystem | Affected published version(s) | Hardened source target | Published by this PR? | +|---|---:|---:|---| +| PyPI `dynamicfeed-verify` | `<= 1.0.2` | `1.0.3` | No | +| npm `@dynamicfeed/verify` | `<= 1.0.1` | `1.0.2` | No | +| crates.io `dynamicfeed-verify` | `1.0.0` (and affected prior source `1.0.1`) | `1.0.2` | No | +| C# sample | all current source | quarantined, transport-only | Not a package | + +Do not announce a clean package rotation until the target artifacts are built from the reviewed +commit, published through their normal release controls, installed from each registry into a clean +environment, and the advisory is updated with artifact digests and provenance. + +See [SECURITY.md](SECURITY.md) for the full advisory. + +## Trust material + +- [`SIGNING_KEY_LIFECYCLE.json`](SIGNING_KEY_LIFECYCLE.json) is the reviewed + `df-signing-key-registry/v1` snapshot. It retains active and historical public bytes with explicit + lifecycle status and `registry_revision`. +- [`KNOWN_KEYS.json`](KNOWN_KEYS.json) is a deprecated compatibility archive. It is intentionally + not a flat map and cannot honestly authorize the compromised key. +- The service compatibility endpoint `/.well-known/keys` is required to become active-key-only as + part of the corresponding service deployment. Historical key bytes belong in the lifecycle + registry. Verify the live endpoint after deployment; this source PR does not deploy it. + +The current-domain registry is an operational disclosure, not an independent trust root. A +security-sensitive verifier should pin a reviewed registry out of band, authenticate updates, and +persist the highest authenticated `registry_revision` outside the document. + +## What policy-aware verification does + +1. Strictly parse JSON and reject duplicate object names, malformed numbers, raw controls, and + trailing bytes. +2. Require exactly `Ed25519` and `json-sorted-compact` signature metadata. +3. Preserve both frozen receipt conventions: verify without `signature` first, then (only when + needed) without both `signature` and a legacy post-signature `anchor`. +4. Derive `key_id` from the SHA-256 fingerprint of the raw public key. +5. Validate the lifecycle registry, its key/fingerprint relationships, chronology, policy fields, + active-key uniqueness, and revision floor. +6. Accept only the current active key in live mode. Retired keys are non-live historical material; + compromised keys are never policy accepted. +7. Keep historical snapshot inspection explicit and non-actionable: it can report signature + mathematics and policy-as-of state but never returns an overall live acceptance. + +`anchor_authenticated=true` means an attached anchor was inside the signed bytes. +`anchor_authenticated=false` means the frozen legacy post-signature attachment was outside them. +Neither state independently validates an RFC 3161 or OpenTimestamps proof; these libraries do not +claim full timestamp-proof verification. + +## Repository map + +| Path | Status | +|---|---| +| [`clients/python`](clients/python) | Python lifecycle-aware verifier, target `1.0.3` | +| [`clients/js`](clients/js) | JavaScript/TypeScript lifecycle-aware verifier, target `1.0.2` | +| [`clients/rust`](clients/rust) | Rust crate `dynamicfeed-verify`, lifecycle-aware target `1.0.2` | +| [`clients/csharp`](clients/csharp) | Quarantined transport-only sample; no reference-verifier claim | +| [`examples/verified-agent`](examples/verified-agent) | Verify-before-use example; never an actuator | +| [`tests/vectors`](tests/vectors) | Shared canonicalization and frozen receipt fixtures | +| [`reliability`](reliability) | Separate non-cryptographic reliability vocabulary and validators | -Reference implementation of **[DF-VERIFY/1](https://dynamicfeed.ai/standard)**, an open, vendor-neutral standard for cryptographically signing, publishing, and **independently verifying exactly what an AI system was told the moment it acted**. +### Rust package provenance -When an AI *acts* (it moves a robot, places a trade, files a claim), "trust me" is not an audit trail. DF-VERIFY attaches an Ed25519 signature to any JSON response, publishes the verifying key openly, and lets anyone check it with **no account and no dependency on the issuer**. You can verify, even against the issuer. +This repository's [`clients/rust`](clients/rust) is the source for the crate named +`dynamicfeed-verify`. The distinct monorepo crate named `df-verify` (`0.1.2` target) is maintained at +`Dynamic-Feed/packages/df-verify-rs`; it is not source-identical or package-identical. Its Cargo +`repository` metadata must point to its actual source location (or that exact source must be added +here under an unambiguous path) before publication. -[![conformance](https://github.com/dynamicfeed/df-verify/actions/workflows/ci.yml/badge.svg)](https://github.com/dynamicfeed/df-verify/actions/workflows/ci.yml) -[![DF-VERIFY/1](https://dynamicfeed.ai/badge.svg)](https://dynamicfeed.ai/standard) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +## Verify from source -Every verifier here (Python, JavaScript, C#, Rust) is held to the **same language-agnostic conformance vectors**, run in CI on every commit. Canonicalization must match byte-for-byte, the authentic signature must verify, and the tampered twin must be rejected. +Python: -## What's here +```bash +python -m pip install -e clients/python pytest +python -m pytest clients/python/tests +python tests/verify_vectors.py +``` -| Path | What | -|---|---| -| [`clients/python`](clients/python) | `dynamicfeed-verify`: Python reference verifier (library + CLI) | -| [`clients/js`](clients/js) | `@dynamicfeed/verify`: JavaScript/TypeScript verifier (Node, Deno, Bun, browser) | -| [`clients/csharp`](clients/csharp) | C# reference verifier | -| [`clients/rust`](clients/rust) | `dynamicfeed-verify`: Rust reference verifier (library + CLI), passes the shared vectors | -| [`examples/verified-agent`](examples/verified-agent) | a runnable agent that verifies a signature **before it acts** | -| [`tests/vectors`](tests/vectors) | language-agnostic conformance vectors + Python & JS harnesses | +JavaScript: -## Verify in 30 seconds +```bash +npm install --prefix clients/js +npm test --prefix clients/js +node tests/verify_vectors.mjs +``` -Both reference verifiers are published. Install one and check a live signed verdict in two lines. +Rust: -**Python** (`pip install dynamicfeed-verify`) -```python -from dynamicfeed_verify import verify_live -env, result = verify_live() # fetch a fresh signed verdict and verify it -assert result["ok"] # tampered or unsigned: this fails +```bash +cd clients/rust +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features ``` -**JavaScript** (`npm i @dynamicfeed/verify`) -```js -import { verifyLive } from '@dynamicfeed/verify'; -const { result } = await verifyLive(); -if (!result.ok) throw new Error(`unverified world-state: ${result.error}`); -``` +Rotation guardrail: -**Or run the demo agent from source** (verify-before-act, and watch it refuse when tampered): ```bash -git clone https://github.com/dynamicfeed/df-verify && cd df-verify -pip install cryptography -python examples/verified-agent/agent.py # verify a live verdict, then act -python examples/verified-agent/agent.py --tamper # altered after signing, the agent refuses to act +python scripts/check_rotation_policy.py ``` -## How it works +## Minimal policy-aware use -1. Drop the `signature` block; keep the rest as the payload. -2. Canonicalize: JSON, keys sorted recursively, compact separators, non-ASCII escaped `\uXXXX`, UTF-8. -3. Fetch the public key from `https://dynamicfeed.ai/.well-known/keys`, look up `signature.key_id`. -4. Verify the Ed25519 signature over the canonical bytes. Change one byte → it fails. +Python: -## Conformance +```python +import json +from dynamicfeed_verify import verify + +registry = json.load(open("SIGNING_KEY_LIFECYCLE.json")) +result = verify( + envelope, + lifecycle_registry=registry, + registry_source_authenticated=True, # caller authenticated this pinned file +) +if not result["ok"]: + raise RuntimeError(result["error"]) +``` -```bash -python3 tests/verify_vectors.py # Python harness → "✓ ALL 10 VECTORS PASS" -node tests/verify_vectors.mjs # JS harness (run: npm install --prefix clients/js first) +JavaScript: + +```js +import { verify } from '@dynamicfeed/verify'; + +const result = await verify(rawEnvelopeText, { + lifecycleRegistry: pinnedRegistry, + registrySourceAuthenticated: true, +}); +if (!result.ok) throw new Error(result.error); ``` -Both reference verifiers reproduce every vector (same canonical bytes, same signature verdicts), so you can confirm a new implementation in any language byte-for-byte. +Rust: + +```rust +let registry = dynamicfeed_verify::LifecycleRegistry::parse_with_options( + ®istry_text, + dynamicfeed_verify::RegistryValidationOptions { + registry_source_authenticated: true, + ..Default::default() + }, +)?; +let result = dynamicfeed_verify::verify_with_registry(&raw_envelope, ®istry)?; +assert!(result.accepted); +``` ## Links -- **Spec:** https://dynamicfeed.ai/standard -- **Verify in your browser:** https://dynamicfeed.ai/proof -- **Public keys (a key_id to Ed25519 public-key map):** https://dynamicfeed.ai/.well-known/keys -- **Discovery manifest:** https://dynamicfeed.ai/.well-known/df-verify.json +- Specification surface: https://dynamicfeed.ai/standard +- Browser verifier: https://dynamicfeed.ai/proof +- Lifecycle registry endpoint: https://dynamicfeed.ai/.well-known/signing-key-registry.json +- Active-key compatibility endpoint: https://dynamicfeed.ai/.well-known/keys ## License MIT. - -## Reliability axis - -Beyond signing *what* was said, the [`reliability/`](reliability) toolkit grades *how much to believe it*: the OKF reliability object (schema + zero-dep Python & JS validators), enforcing `signed != verified`. See [reliability/README.md](reliability/README.md). diff --git a/SECURITY.md b/SECURITY.md index feea92a..2f70a1e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,18 +1,102 @@ -# Security Policy +# Security policy -This repository is verification tooling. The security-critical surface is the verifiers themselves: the canonicalization and the Ed25519 check. The failure that matters most is a verifier that **accepts a tampered or invalid signature as valid**, or that canonicalizes differently from the signer so a genuine signature is rejected. +## Advisory DFF-2026-07-11-01 — signer lifecycle bypass + +Status: source fixes prepared; package publication and service rollout are separate release gates. + +Earlier Python, JavaScript, and Rust verifiers could treat successful Ed25519 mathematics against a +flat key map as an overall accepted result. Flat key bytes contain no lifecycle status. After a key +is retired or compromised, a lifecycle-unaware verifier can therefore accept newly forged bytes as +if the signer were still authorized. + +The former key `df-ed25519-4cb32e72f333` is classified compromised using the conservative boundary +`2026-07-11T00:00:00Z`. Its public key remains available for historical analysis. A caller-reported +`issued_at`, a bundle-embedded key, a self-declared registry revision, or an unverified timestamp +artifact cannot rehabilitate it. + +### Affected and target versions + +| Package/source | Affected | Hardened target | +|---|---:|---:| +| PyPI `dynamicfeed-verify` | `<= 1.0.2` | `1.0.3` | +| npm `@dynamicfeed/verify` | `<= 1.0.1` | `1.0.2` | +| crates.io `dynamicfeed-verify` | published `1.0.0`; prior repository source `1.0.1` | `1.0.2` | +| C# sample | all current source | quarantined pending a conformant implementation | + +These target numbers describe source in this repository. They are not release attestations. Check +the package registry and the final release advisory before depending on them. + +### Corrected behavior + +- signature mathematics is reported separately from signer acceptance; +- exact algorithm and canonicalization metadata are enforced; +- public key bytes must derive the declared key ID; +- duplicate JSON names and malformed JSON fail closed; +- both frozen anchor signature scopes remain compatible, with explicit anchor-authentication state; +- lifecycle registries require complete key/status/fingerprint/chronology/policy relationships; +- live mode accepts only the registry's active key; +- caller-supplied registries and custom network origins require an explicit authenticated-source + assertion before live policy can pass, and network redirects fail closed; +- retired material is historical and non-live; +- compromised keys are never policy accepted; +- `historical_snapshot` mode is explicit and never actionable; +- live registry revisions enforce the compiled, caller-supplied, and caller-retained floors; +- rollback protection is claimed only when the caller supplies authenticated retained state. + +### Trust-boundary warning + +`SIGNING_KEY_LIFECYCLE.json` is a reviewed out-of-band snapshot in this source repository. The same +document served by `dynamicfeed.ai` is domain-authenticated operational disclosure, not an +independent lifecycle authority. Security gates should pin trust material outside the receipt +channel and authenticate registry updates. + +`KNOWN_KEYS.json` is deprecated and deliberately non-flat. It preserves both public keys and marks +the former key compromised. It must not be converted back into an additive list of accepted keys. + +### C# status + +The C# sample is not a reference verifier. It lacks conformance-tested canonicalization, lifecycle, +and anti-rollback logic. Its network response is explicitly unverified; compatibility methods that +could return an actionable verdict throw `NotSupportedException`. + +### Package and deployment completion criteria + +Do not call the rotation clean until all of the following are recorded: + +1. the reviewed source commit and passing CI; +2. package builds from that exact commit; +3. registry publication through normal release controls; +4. clean-environment installation and compromised-key rejection tests for every package; +5. artifact hashes and source provenance; +6. service deployment where `/.well-known/keys` exposes only the active key and the lifecycle + registry retains historical keys with status; +7. live endpoint, logs, and rollback verification; +8. advisory update naming the released versions and completion time. + +This repository does not publish packages or deploy the service merely by merging a source PR. ## Reporting a vulnerability Please report privately rather than opening a public issue: -- GitHub: use **Security > Report a vulnerability** (private advisory) on this repo, or -- Email: **hello@dynamicfeed.ai** - -Include the affected verifier (Python / JS / C# / Rust), a minimal reproduction, and the expected versus actual verdict. A reproduction against the vectors in [`tests/vectors`](tests/vectors) is ideal. +- GitHub: use **Security > Report a vulnerability** on this repository; or +- email: **hello@dynamicfeed.ai**. -We will acknowledge within a few days and work with you on a fix and disclosure timeline. +Include the affected implementation, version/commit, minimal reproduction, trust material used, +and expected versus actual `crypto_valid` and policy result. A fixture based on +[`tests/vectors`](tests/vectors) is ideal. ## Scope -In scope: any verifier producing an incorrect verdict (a false accept or a false reject), or a canonicalization that diverges from the published `json-sorted-compact` rule. Out of scope: the availability of the live `dynamicfeed.ai` endpoints (this toolkit is designed to verify offline, with no runtime dependency on the issuer). +In scope: + +- false cryptographic acceptance or rejection; +- signer lifecycle or registry anti-rollback bypass; +- canonicalization divergence; +- key-ID/fingerprint confusion; +- duplicate-key parsing differences; +- anchor-scope confusion; +- a sample or CLI that turns crypto-only or unverified data into an actionable policy result. + +Endpoint availability is outside this verifier repository's scope, but unsafe fallback from an +unavailable lifecycle registry is in scope. diff --git a/SIGNING_KEY_LIFECYCLE.json b/SIGNING_KEY_LIFECYCLE.json new file mode 100644 index 0000000..0645084 --- /dev/null +++ b/SIGNING_KEY_LIFECYCLE.json @@ -0,0 +1,70 @@ +{ + "schema": "df-signing-key-registry/v1", + "issuer": "dynamicfeed.ai", + "registry_revision": 1, + "updated_at": "2026-07-11T10:17:26Z", + "active_key_id": "df-ed25519-6ca0de29113b", + "public_keys": { + "df-ed25519-4cb32e72f333": "O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk=", + "df-ed25519-6ca0de29113b": "acMWky59eQfoVqCUgmUM3tcl35YioRngL4wVDIQsstg=" + }, + "lifecycle": { + "df-ed25519-4cb32e72f333": { + "alg": "Ed25519", + "use": "receipt-signing", + "fingerprint_sha256": "4cb32e72f33309dcfef9e8a887a389463bf8cbe607104c48c1555e8881d0a119", + "status": "compromised", + "custody_class": "railway-environment-seed", + "first_used_at": "2026-06-06T00:00:00Z", + "active_from": "2026-06-06T00:00:00Z", + "retired_at": "2026-07-11T09:49:41Z", + "compromised_after": "2026-07-11T00:00:00Z", + "compromise_time_basis": "Conservative start-of-UTC-day boundary for the known exposure incident; the exact exposure instant is not claimed.", + "public_key_retained": true + }, + "df-ed25519-6ca0de29113b": { + "alg": "Ed25519", + "use": "receipt-signing", + "fingerprint_sha256": "6ca0de29113bb8dd8806a56c00f19c626320b8f6309e34a6c49921b10f8445cd", + "status": "active", + "custody_class": "railway-environment-seed", + "first_used_at": "2026-07-11T09:49:41Z", + "active_from": "2026-07-11T09:49:41Z", + "retired_at": null, + "compromised_after": null, + "public_key_retained": true + } + }, + "authority": { + "classification": "domain-authenticated-operational-disclosure", + "normative": false, + "independent_registry_root": false, + "note": "The HTTP response is signed by the active data-signing key. That proves the current Dynamic Feed signer made this disclosure; it is not an independent trust root. Live-policy consumers must authenticate updates and retain a monotonic minimum registry_revision outside this document." + }, + "rollback_protection": { + "live_policy_minimum_revision": 1, + "live_policy_requires_authenticated_update": true, + "historical_snapshot_mode_must_be_explicit": true, + "self_asserted_revision_is_not_anti_rollback": true + }, + "compatibility": { + "legacy_key_set": "/.well-known/keys", + "legacy_flat_map_unchanged": false, + "legacy_flat_map_scope": "active-key-only-after-2026-07-11-compromise", + "historical_key_source": "/.well-known/signing-key-registry.json", + "reason": "Lifecycle-unaware clients must fail closed for compromised keys; historical bytes remain available with explicit status." + }, + "verification_policy": { + "cryptographic_validity_separate_from_lifecycle": true, + "retired_keys_verify_historical_bytes": true, + "compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp": true, + "receipt_issued_at_alone_is_not_independent_time_evidence": true + }, + "limitations": [ + "This registry is an operational disclosure, not a standards-body or legal approval.", + "A self-signature by the active data key is not an independent lifecycle authority; pin this document or its keys outside this domain before using it as a policy gate.", + "The compromised_after boundary is deliberately conservative and has day-level incident precision.", + "registry_revision enables caller-enforced anti-rollback but is not self-enforcing; a live verifier must retain its minimum accepted revision through an authenticated channel. Historical snapshot verification is a separate explicit mode.", + "Dynamic Feed does not claim that every RFC 3161 or OpenTimestamps proof in the current implementation has received complete independent cryptographic validation." + ] +} diff --git a/clients/csharp/AwarenessClient.cs b/clients/csharp/AwarenessClient.cs index 6bba936..5e81a91 100644 --- a/clients/csharp/AwarenessClient.cs +++ b/clients/csharp/AwarenessClient.cs @@ -1,13 +1,9 @@ -// AwarenessClient.cs — reference C# client for the Dynamic Feed robot situational-awareness API. -// Target: .NET 6+. No external packages required for the call itself (System.Text.Json + HttpClient). -// Signature verification (optional) needs BouncyCastle — see the note at the bottom. +// AwarenessClient.cs — QUARANTINED transport-only C# sample. // -// var df = new DynamicFeed.AwarenessClient(); -// string verdict = await df.VerdictAsync("aerial", 51.5, -0.12); // "go" | "caution" | "no-go" -// if (verdict != "go") AbortTakeoff(); -// -// The server NEVER hangs (hard deadline; degrades to "caution"), and this client mirrors that -// fail-safe contract: any error returns "caution", never an exception into your control loop. +// This file does NOT implement DF-VERIFY/1 canonicalization, key-id binding, signing-key +// lifecycle validation, or registry anti-rollback. It therefore MUST NOT return an actionable +// verdict and is not a reference verifier. Use the Python, JavaScript, or Rust implementation +// until a conformance-tested C# lifecycle-aware verifier exists. using System; using System.Net.Http; @@ -18,6 +14,10 @@ namespace DynamicFeed { + /// + /// Transport-only access to an unverified awareness response. No method in this class may be + /// used to authorize physical actuation, trading, filing, or another consequential action. + /// public sealed class AwarenessClient { private static readonly HttpClient Http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; @@ -26,8 +26,11 @@ public sealed class AwarenessClient public AwarenessClient(string baseUrl = "https://dynamicfeed.ai") => _baseUrl = baseUrl.TrimEnd('/'); - /// Full awareness snapshot (verdict + grounded facts + Ed25519 signature) as JSON. - public async Task AwarenessAsync( + /// + /// Fetches raw JSON for inspection only. The returned object has not been verified and + /// carries no trust or lifecycle verdict. + /// + public async Task FetchUnverifiedAwarenessAsync( string robotClass, double lat, double lon, double? altM = null, CancellationToken ct = default) { @@ -44,37 +47,17 @@ public async Task AwarenessAsync( return JsonDocument.Parse(text).RootElement.Clone(); } - /// - /// Returns "go" | "caution" | "no-go". Fail-safe: on ANY error (timeout, network, parse) it - /// returns "caution" rather than throwing — safe to call from a real-time control loop. - /// - public async Task VerdictAsync( + [Obsolete("Quarantined: no lifecycle-aware C# verifier exists. Use FetchUnverifiedAwarenessAsync only for inspection.")] + public Task AwarenessAsync( + string robotClass, double lat, double lon, double? altM = null, + CancellationToken ct = default) + => throw new NotSupportedException( + "C# policy verification is quarantined; use a conformance-tested Python, JavaScript, or Rust verifier."); + + [Obsolete("Quarantined: an unverified network verdict must never authorize an action.")] + public Task VerdictAsync( string robotClass, double lat, double lon, CancellationToken ct = default) - { - try - { - var root = await AwarenessAsync(robotClass, lat, lon, null, ct); - return root.GetProperty("verdict").GetProperty("status").GetString() ?? "caution"; - } - catch - { - return "caution"; - } - } + => throw new NotSupportedException( + "C# verdict extraction is disabled until lifecycle-aware verification is implemented and tested."); } } - -// ── Signature verification (optional, recommended for tamper-evidence) ──────────────────────────── -// Every response carries: "signature": { "alg":"Ed25519", "key_id":"...", "canonicalization": -// "json-sorted-compact", "sig":"" }. -// To verify: -// 1. GET https://dynamicfeed.ai/.well-known/keys → { "": "" }. -// 2. Re-serialize the response WITHOUT its "signature" and "anchor" fields as canonical JSON -// (`anchor` is added after signing so any holder can independently RFC 3161 timestamp the body) -// (UTF-8, keys sorted, separators "," and ":"). -// 3. Verify with BouncyCastle: -// var verifier = new Org.BouncyCastle.Crypto.Signers.Ed25519Signer(); -// verifier.Init(false, new Ed25519PublicKeyParameters(rawPublicKeyBytes, 0)); -// verifier.BlockUpdate(canonicalBytes, 0, canonicalBytes.Length); -// bool ok = verifier.VerifySignature(signatureBytes); -// If ok is false, the snapshot was altered — reject it. diff --git a/clients/js/README.md b/clients/js/README.md index 9c36500..f403499 100644 --- a/clients/js/README.md +++ b/clients/js/README.md @@ -1,13 +1,17 @@ # @dynamicfeed/verify -Independently verify [Dynamic Feed](https://dynamicfeed.ai) **DF-VERIFY/1** Ed25519-signed responses in any JavaScript runtime (Node ≥18, Deno, Bun, browsers). No account, no runtime trust in Dynamic Feed beyond fetching the public key. You can verify, even against us. +Verify [Dynamic Feed](https://dynamicfeed.ai) **DF-VERIFY/1** signed bytes and signing-key lifecycle policy in any JavaScript runtime (Node ≥18, Deno, Bun, browsers). The default fetch is current-domain policy; origin-independent verification requires an out-of-band pinned lifecycle registry. Reference implementation of the [DF-VERIFY/1 standard](https://dynamicfeed.ai/standard). Byte-for-byte identical canonicalization to the Python (`dynamicfeed-verify`) and in-browser verifiers. ## Install +`1.0.2` is the hardened source target in this branch. Until the security advisory records that it +has been published and clean-install tested, install from the reviewed source commit rather than +assuming npm is current: + ```bash -npm install @dynamicfeed/verify +npm install --prefix clients/js ``` ## Use @@ -19,12 +23,18 @@ import { verify, verifyLive } from '@dynamicfeed/verify'; const { text, result } = await verifyLive(); console.log(result); // { ok: true, keyId: 'df-ed25519-…', verdict: 'caution', ... } -// 2) verify any signed response you hold (pass the RAW text for byte-fidelity) +// 2) verify any signed response you hold — pass the RAW text for byte-fidelity const result2 = await verify(rawResponseText); if (!result2.ok) throw new Error(`unverified: ${result2.error}`); -// 3) verify fully offline if you already have the JWKS -const result3 = await verify(rawResponseText, { jwks: { 'df-ed25519-…': '' } }); +// 3) verify fully offline with an out-of-band pinned df-signing-key-registry/v1 document +const result3 = await verify(rawResponseText, { + lifecycleRegistry: pinnedRegistry, + registrySourceAuthenticated: true, +}); + +// A legacy flat key map reports signature mathematics only: cryptoValid may be true, but ok stays false. +const cryptoOnly = await verify(rawResponseText, { jwks: { 'df-ed25519-…': '' } }); ``` ## CLI @@ -38,10 +48,20 @@ npx @dynamicfeed/verify - < response.json # verify a saved signed response A signed response carries a `signature` block (`alg`, `key_id`, `canonicalization`, `sig`). Verification: -1. Drop the `signature` field; keep the rest as the payload. -2. Canonicalize: JSON, keys sorted recursively, compact separators (`,` `:`), non-ASCII escaped `\uXXXX`, UTF-8. (Numbers are preserved verbatim via a lossless parse, so it matches the signer byte-for-byte.) -3. Fetch the public key from `/.well-known/keys` and look up `signature.key_id`. -4. Verify the Ed25519 signature over the canonical bytes. Change one byte → it fails. +1. Require the exact `Ed25519` / `json-sorted-compact` signature metadata. +2. Preserve both frozen anchor conventions: try the document without `signature` first, then (only + if needed) without both `signature` and `anchor`. `anchorAuthenticated` reports `true`, `false`, + or `null`; signature validity never authenticates a legacy post-signature anchor. +3. Canonicalize — JSON, keys sorted recursively, compact separators (`,` `:`), non-ASCII escaped `\uXXXX`, UTF-8. (Numbers are preserved verbatim via a lossless parse, so it matches the signer byte-for-byte.) +4. Resolve the key bytes and lifecycle state from `/.well-known/signing-key-registry.json` (or a pinned copy). +5. Verify the Ed25519 signature over the canonical bytes and bind the public key to `key_id`. +6. Return `ok: true` only when cryptography and lifecycle policy both pass. Compromised keys remain available for reporting historical `cryptoValid`, but never turn green from `issued_at` or bundle metadata. + +Live mode enforces the compiled revision floor plus `minimumRegistryRevision` and the caller's +persisted `highestAuthenticatedRegistryRevision`. A registry's self-declared revision is not +anti-rollback. Caller-supplied registries and custom network origins require +`registrySourceAuthenticated: true`; otherwise signature mathematics may pass but `ok` remains +false. Redirects fail closed. `historical_snapshot` is explicit and always non-actionable. Full specification: **https://dynamicfeed.ai/standard** diff --git a/clients/js/cli.js b/clients/js/cli.js index 8094343..ff9339d 100644 --- a/clients/js/cli.js +++ b/clients/js/cli.js @@ -8,9 +8,14 @@ function report(res) { if (res.verdict) extra += ` · verdict=${res.verdict}`; if (res.snapshot) extra += ` · snapshot=${res.snapshot}`; if (res.ephemeral) extra += ' · EPHEMERAL key'; - console.log(`✅ VALID — key=${res.keyId}${extra}`); + console.log(`✅ POLICY ACCEPTED — signature valid · lifecycle=${res.lifecycleStatus} · key=${res.keyId}${extra}`); process.exit(0); } + if (res.cryptoValid) { + console.log(`△ CRYPTOGRAPHICALLY VALID, LIFECYCLE REJECTED — key=${res.keyId} · ` + + `status=${res.lifecycleStatus || 'unknown'} · ${res.error || 'not accepted'}`); + process.exit(1); + } console.log(`✗ INVALID — ${res.error}`); process.exit(1); } @@ -19,7 +24,7 @@ const a = process.argv.slice(2); if (a[0] === '-h' || a[0] === '--help') { console.log('usage:\n' + ' dynamicfeed-verify [BASE_URL] fetch a live signed verdict and verify it\n' + - ' dynamicfeed-verify - < response.json verify a saved signed response\n' + + ' dynamicfeed-verify - [BASE_URL] < response.json verify saved bytes using current-domain lifecycle policy\n' + ` default BASE_URL = ${DEFAULT_BASE} · spec: ${DEFAULT_BASE}/standard`); process.exit(0); } diff --git a/clients/js/index.d.ts b/clients/js/index.d.ts index 2a1c855..2526999 100644 --- a/clients/js/index.d.ts +++ b/clients/js/index.d.ts @@ -2,10 +2,30 @@ // https://dynamicfeed.ai/standard export declare const DEFAULT_BASE: string; +export declare const COMPILED_MIN_REGISTRY_REVISION: number; export interface VerifyResult { - /** true iff the Ed25519 signature is authentic over the canonical payload. */ + /** true iff signature mathematics, key binding, and lifecycle policy all pass. */ ok: boolean; + /** Ed25519 validity over the matched canonical signed scope, independent of lifecycle. */ + cryptoValid?: boolean; + keyBindingValid?: boolean; + /** Whether the key is accepted by the supplied/fetched lifecycle registry. */ + signerAccepted?: boolean; + lifecycleStatus?: "active" | "retired" | "compromised" | "unknown"; + lifecycle?: { status: string; accepted: boolean; reason: string; compromisedAfter?: string }; + trustBasis?: "current-domain" | "caller-configured-network-origin" | "out-of-band" | "key-bytes-only"; + policyAsOfAccepted?: boolean; + mode?: "live" | "historical_snapshot"; + historicalSnapshot?: boolean; + registryRevision?: number | null; + effectiveRegistryFloor?: number; + registrySource?: "current-domain-https" | "caller-configured-network-origin" | "out-of-band" | "key-bytes-only"; + /** Explicitly assert that caller-supplied/custom-origin registry material was authenticated. */ + registrySourceAuthenticated?: boolean; + rollbackProtected?: boolean; + /** true when an anchor was signed, false for legacy post-signature attachment, null without one. */ + anchorAuthenticated?: boolean | null; /** present when ok=false: why verification failed. */ error?: string; keyId?: string; @@ -20,12 +40,20 @@ export interface VerifyResult { } export interface VerifyOptions { - /** base URL to fetch the public key set from (default https://dynamicfeed.ai). */ + /** base URL to fetch the no-store lifecycle registry from (default https://dynamicfeed.ai). */ base?: string; - /** supply a JWKS map (key_id → base64url public key) to verify fully offline. */ + /** Legacy flat key map: reports cryptoValid but cannot make ok=true without lifecycle policy. */ jwks?: Record; + /** Supply an out-of-band pinned df-signing-key-registry/v1 document for offline policy. */ + lifecycleRegistry?: object; + verificationMode?: "live" | "historical_snapshot"; + minimumRegistryRevision?: number; + highestAuthenticatedRegistryRevision?: number; + registrySourceAuthenticated?: boolean; } +export declare function validateLifecycleRegistry(registry: object, opts?: VerifyOptions): Promise; + /** The exact bytes that were signed: the response minus its `signature`, json-sorted-compact. */ export declare function canonical(input: string | object): string; diff --git a/clients/js/index.js b/clients/js/index.js index 75c8c8b..670aedc 100644 --- a/clients/js/index.js +++ b/clients/js/index.js @@ -3,15 +3,21 @@ // Runtime-agnostic ESM (Node >=18, Deno, Bun, browsers/bundlers). Reproduces the server's // canonicalization BYTE-FOR-BYTE — json-sorted-compact, Python `ensure_ascii` escaping, the // `signature` field stripped, numbers preserved verbatim via a lossless parse — then verifies the -// detached Ed25519 signature against the published key set using @noble/ed25519. Proven byte-identical -// to the Python reference verifier (clients/python) over the shared conformance vectors (tests/vectors). +// detached Ed25519 signature against the published JWKS using @noble/ed25519. Proven identical to +// the Python (scripts/verify_awareness.py) and in-browser (web/verify.js) reference verifiers. // // Spec: https://dynamicfeed.ai/standard import * as ed from '@noble/ed25519'; export const DEFAULT_BASE = 'https://dynamicfeed.ai'; +export const COMPILED_MIN_REGISTRY_REVISION = 1; function b64u(s) { + const padding = typeof s === 'string' ? ((s.match(/=+$/) || [''])[0].length) : 0; + const coreLength = typeof s === 'string' ? s.length - padding : 0; + if (typeof s !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(s) || coreLength % 4 === 1 + || (padding && s.length % 4 !== 0)) + throw new Error('invalid base64url'); s = s.replace(/-/g, '+').replace(/_/g, '/'); s += '='.repeat((4 - (s.length % 4)) % 4); const bin = atob(s), u = new Uint8Array(bin.length); @@ -21,23 +27,36 @@ function b64u(s) { // lossless JSON parse — numbers kept VERBATIM so re-canonicalization matches Python's repr exactly function lparse(s) { + if (typeof s !== 'string') throw new Error('input must be text'); let i = 0; + function fail(msg) { throw new Error(msg + ' @' + i); } function ws() { while (i < s.length) { const c = s[i]; if (c === ' ' || c === '\t' || c === '\n' || c === '\r') i++; else break; } } function val() { ws(); const c = s[i]; if (c === '{') return obj(); if (c === '[') return arr(); if (c === '"') return { t: 's', v: str() }; - if (c === 't') { i += 4; return { t: 'b', v: true }; } if (c === 'f') { i += 5; return { t: 'b', v: false }; } - if (c === 'n') { i += 4; return { t: 'z' }; } return num(); } - function obj() { i++; const p = []; ws(); if (s[i] === '}') { i++; return { t: 'o', v: p }; } - for (;;) { ws(); const k = str(); ws(); i++; const v = val(); p.push([k, v]); ws(); - if (s[i] === ',') { i++; continue; } if (s[i] === '}') { i++; break; } throw new Error('object @' + i); } return { t: 'o', v: p }; } + if (s.slice(i, i + 4) === 'true') { i += 4; return { t: 'b', v: true }; } + if (s.slice(i, i + 5) === 'false') { i += 5; return { t: 'b', v: false }; } + if (s.slice(i, i + 4) === 'null') { i += 4; return { t: 'z' }; } + if (c === '-' || /[0-9]/.test(c || '')) return num(); fail('unexpected token'); } + function obj() { i++; const p = [], seen = new Set(); ws(); if (s[i] === '}') { i++; return { t: 'o', v: p }; } + for (;;) { ws(); const k = str(); ws(); if (s[i++] !== ':') fail('expected colon'); + if (seen.has(k)) fail('duplicate object key'); seen.add(k); const v = val(); p.push([k, v]); ws(); + if (s[i] === ',') { i++; continue; } if (s[i] === '}') { i++; break; } fail('expected comma or object end'); } return { t: 'o', v: p }; } function arr() { i++; const a = []; ws(); if (s[i] === ']') { i++; return { t: 'a', v: a }; } - for (;;) { a.push(val()); ws(); if (s[i] === ',') { i++; continue; } if (s[i] === ']') { i++; break; } throw new Error('array @' + i); } return { t: 'a', v: a }; } - function str() { let r = ''; i++; while (s[i] !== '"') { if (s[i] === '\\') { const e = s[i + 1]; - if (e === 'u') { r += String.fromCharCode(parseInt(s.slice(i + 2, i + 6), 16)); i += 6; } - else { r += ({ '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' })[e]; i += 2; } } - else { r += s[i]; i++; } } i++; return r; } - function num() { const a = i; while (i < s.length && '-+0123456789.eE'.indexOf(s[i]) >= 0) i++; return { t: 'n', v: s.slice(a, i) }; } - return val(); + for (;;) { a.push(val()); ws(); if (s[i] === ',') { i++; continue; } if (s[i] === ']') { i++; break; } fail('expected comma or array end'); } return { t: 'a', v: a }; } + function str() { if (s[i] !== '"') fail('expected string'); let r = ''; i++; + while (i < s.length) { const ch = s[i++]; if (ch === '"') return r; + if (ch === '\\') { if (i >= s.length) fail('unterminated escape'); const e = s[i++]; + if (e === 'u') { const hex = s.slice(i, i + 4); if (!/^[0-9A-Fa-f]{4}$/.test(hex)) fail('invalid unicode escape'); r += String.fromCharCode(parseInt(hex, 16)); i += 4; } + else { const escapes = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; + if (!Object.prototype.hasOwnProperty.call(escapes, e)) fail('invalid escape'); r += escapes[e]; } } + else { if (ch.charCodeAt(0) < 0x20) fail('unescaped control character'); r += ch; } } fail('unterminated string'); } + function num() { const a = i; if (s[i] === '-') i++; if (s[i] === '0') i++; + else if (/[1-9]/.test(s[i] || '')) while (/[0-9]/.test(s[i] || '')) i++; else fail('invalid number'); + if (s[i] === '.') { i++; if (!/[0-9]/.test(s[i] || '')) fail('invalid fraction'); while (/[0-9]/.test(s[i] || '')) i++; } + if (s[i] === 'e' || s[i] === 'E') { i++; if (s[i] === '+' || s[i] === '-') i++; + if (!/[0-9]/.test(s[i] || '')) fail('invalid exponent'); while (/[0-9]/.test(s[i] || '')) i++; } + return { t: 'n', v: s.slice(a, i) }; } + const root = val(); ws(); if (i !== s.length) fail('trailing data'); return root; } // Python json.dumps string escaping with ensure_ascii=True @@ -51,7 +70,9 @@ function pys(str) { let o = '"'; for (const ch of str) { const c = ch.codePointA else o += '\\u' + c.toString(16).padStart(4, '0'); } return o + '"'; } -function canon(n) { if (n.t === 'o') { const keys = n.v.map(p => p[0]).sort(); const m = {}; n.v.forEach(p => m[p[0]] = p[1]); +function pyKeyCmp(a, b) { const aa = Array.from(a, ch => ch.codePointAt(0)), bb = Array.from(b, ch => ch.codePointAt(0)); + const n = Math.min(aa.length, bb.length); for (let i = 0; i < n; i++) if (aa[i] !== bb[i]) return aa[i] - bb[i]; return aa.length - bb.length; } +function canon(n) { if (n.t === 'o') { const keys = n.v.map(p => p[0]).sort(pyKeyCmp); const m = {}; n.v.forEach(p => m[p[0]] = p[1]); return '{' + keys.map(k => pys(k) + ':' + canon(m[k])).join(',') + '}'; } if (n.t === 'a') return '[' + n.v.map(canon).join(',') + ']'; if (n.t === 's') return pys(n.v); if (n.t === 'n') return n.v; @@ -59,20 +80,58 @@ function canon(n) { if (n.t === 'o') { const keys = n.v.map(p => p[0]).sort(); c function field(node, key) { if (!node || node.t !== 'o') return null; for (const [k, v] of node.v) if (k === key) return v; return null; } -// Pinned keys (offline fallback): receipts stay verifiable even if the domain is unreachable. -const KNOWN_KEYS = { 'df-ed25519-4cb32e72f333': 'O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk=' }; -async function fetchJwks(base) { - try { - const r = await fetch(base + '/.well-known/keys'); - if (!r.ok) throw new Error('HTTP ' + r.status); - const live = await r.json(); - const merged = { ...KNOWN_KEYS }; - for (const [k, v] of Object.entries(live)) if (typeof v === 'string') merged[k] = v; - return merged; - } catch (e) { - return { ...KNOWN_KEYS }; +async function keyIdFor(pub) { const h = new Uint8Array(await crypto.subtle.digest('SHA-256', pub)); + return 'df-ed25519-' + [...h.slice(0, 6)].map(b => b.toString(16).padStart(2, '0')).join(''); } +function utcMillis(value, name) { const m = typeof value === 'string' && value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/); + if (!m) throw new Error(name + ' must be RFC3339 UTC'); const n = Date.parse(value), d = new Date(n), p = m.slice(1, 7).map(Number); + if (!Number.isFinite(n) || d.getUTCFullYear() !== p[0] || d.getUTCMonth() + 1 !== p[1] || d.getUTCDate() !== p[2] + || d.getUTCHours() !== p[3] || d.getUTCMinutes() !== p[4] || d.getUTCSeconds() !== p[5]) throw new Error(name + ' is invalid'); return n; } +function revisionContext(raw, opts = {}) { const mode = opts.verificationMode || 'live'; + if (mode !== 'live' && mode !== 'historical_snapshot') throw new Error('verificationMode must be live or historical_snapshot'); + const revision = raw && raw.registry_revision; + if (!Number.isInteger(revision) || revision < 1) throw new Error('registry_revision must be a positive integer'); + for (const [name, value] of [['minimumRegistryRevision', opts.minimumRegistryRevision], + ['highestAuthenticatedRegistryRevision', opts.highestAuthenticatedRegistryRevision]]) { + if (value !== undefined && value !== null && (!Number.isInteger(value) || value < 1)) + throw new Error(name + ' must be a positive integer'); } -} + const floor = mode === 'historical_snapshot' ? 0 : Math.max(COMPILED_MIN_REGISTRY_REVISION, + opts.minimumRegistryRevision || COMPILED_MIN_REGISTRY_REVISION, + opts.highestAuthenticatedRegistryRevision || 0); + if (mode === 'live' && revision < floor) throw new Error('registry revision ' + revision + ' is below effective floor ' + floor); + return { mode, registryRevision: revision, effectiveRegistryFloor: floor, + historicalSnapshot: mode === 'historical_snapshot' }; } +export async function validateLifecycleRegistry(raw, opts = {}) { if (!raw || typeof raw !== 'object' || Array.isArray(raw) + || raw.schema !== 'df-signing-key-registry/v1' || raw.issuer !== 'dynamicfeed.ai') throw new Error('invalid lifecycle registry identity'); + revisionContext(raw, opts); utcMillis(raw.updated_at, 'updated_at'); const keys = raw.public_keys, life = raw.lifecycle, activeId = raw.active_key_id; + if (!keys || !life || typeof keys !== 'object' || typeof life !== 'object' || Array.isArray(keys) || Array.isArray(life)) throw new Error('invalid lifecycle registry maps'); + const ids = Object.keys(keys).sort(), lids = Object.keys(life).sort(); if (!ids.length || ids.length !== lids.length || ids.some((x, i) => x !== lids[i])) throw new Error('incomplete lifecycle registry'); + let active = 0; for (const id of ids) { const pub = b64u(keys[id]); if (pub.length !== 32 || await keyIdFor(pub) !== id) throw new Error('key id binding mismatch'); + const meta = life[id]; if (!meta || meta.alg !== 'Ed25519' || meta.use !== 'receipt-signing' || !['active', 'retired', 'compromised'].includes(meta.status)) throw new Error('invalid lifecycle entry'); + const fingerprint = [...new Uint8Array(await crypto.subtle.digest('SHA-256', pub))].map(b => b.toString(16).padStart(2, '0')).join(''); + if (meta.fingerprint_sha256 !== fingerprint || meta.public_key_retained !== true) throw new Error('lifecycle fingerprint/retention mismatch'); + const first = utcMillis(meta.first_used_at, id + '.first_used_at'), from = utcMillis(meta.active_from, id + '.active_from'); if (first > from) throw new Error('invalid lifecycle interval'); + if (meta.status === 'active') { active++; if (id !== activeId || meta.retired_at !== null || meta.compromised_after !== null) throw new Error('active lifecycle mismatch'); } + else { const retired = utcMillis(meta.retired_at, id + '.retired_at'); if (from > retired) throw new Error('invalid retirement interval'); + if (meta.status === 'compromised') { if (utcMillis(meta.compromised_after, id + '.compromised_after') > retired) throw new Error('invalid compromise boundary'); } + else if (meta.compromised_after !== null) throw new Error('retired key carries compromise metadata'); } + } + if (active !== 1 || !Object.prototype.hasOwnProperty.call(keys, activeId) || !raw.authority || raw.authority.independent_registry_root !== false) throw new Error('invalid registry authority/active key'); + const policy = raw.verification_policy || {}; for (const n of ['cryptographic_validity_separate_from_lifecycle', 'retired_keys_verify_historical_bytes', + 'compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp', 'receipt_issued_at_alone_is_not_independent_time_evidence']) if (policy[n] !== true) throw new Error('incomplete registry verification policy'); + return raw; } +function lifecyclePolicy(registry, keyId, mode = 'live') { const meta = registry && registry.lifecycle && registry.lifecycle[keyId]; + if (!meta) return { status: 'unknown', accepted: false, reason: 'key absent from lifecycle registry' }; + if (mode === 'historical_snapshot') return meta.status === 'compromised' + ? { status: 'compromised', accepted: false, policyAsOfAccepted: false, + reason: 'compromised key; no verified signature-inclusive pre-boundary commitment', compromisedAfter: meta.compromised_after } + : { status: meta.status, accepted: false, policyAsOfAccepted: true, + reason: 'historical snapshot is non-live and non-actionable; policy-as-of is reported separately' }; + if (meta.status === 'active') return { status: 'active', accepted: registry.active_key_id === keyId, reason: 'active lifecycle key' }; + if (meta.status === 'retired') return { status: 'retired', accepted: false, reason: 'retired key is not accepted for live verification' }; + return { status: 'compromised', accepted: false, reason: 'compromised key; no verified signature-inclusive pre-boundary commitment', compromisedAfter: meta.compromised_after }; } +async function fetchRegistry(base, opts) { const r = await fetch(base + '/.well-known/signing-key-registry.json', { cache: 'no-store', redirect: 'error' }); + if (!r.ok) throw new Error('HTTP ' + r.status); const text = await r.text(); lparse(text.trim()); return validateLifecycleRegistry(JSON.parse(text), opts); } /** * The exact bytes that were signed: the response minus its `signature` field, json-sorted-compact. @@ -83,19 +142,22 @@ async function fetchJwks(base) { export function canonical(input) { const text = typeof input === 'string' ? input : JSON.stringify(input); const root = lparse(text.trim()); - // `anchor` is added AFTER signing (holder-side RFC 3161 timestamping); never signed, always stripped. - const stripped = root.t === 'o' ? { t: 'o', v: root.v.filter(p => p[0] !== 'signature' && p[0] !== 'anchor') } : root; + const stripped = root.t === 'o' ? { t: 'o', v: root.v.filter(p => p[0] !== 'signature') } : root; return canon(stripped); } /** * Verify a DF-VERIFY/1 signed response. * @param {string|object} input Raw response text (preferred) or a parsed object. - * @param {{base?:string, jwks?:Record}} [opts] base URL for key fetch; or supply jwks to verify offline. - * @returns {Promise<{ok:boolean, error?:string, keyId?:string, alg?:string, canon?:string, ephemeral?:boolean, verdict?:string|null, snapshot?:string}>} + * Caller-supplied registries and custom network origins require + * registrySourceAuthenticated=true before live policy can pass. Redirects fail closed. + * @param {{base?:string, jwks?:Record, lifecycleRegistry?:object, verificationMode?:'live'|'historical_snapshot', minimumRegistryRevision?:number, highestAuthenticatedRegistryRevision?:number, registrySourceAuthenticated?:boolean}} [opts] + * @returns {Promise<{ok:boolean, cryptoValid?:boolean, signerAccepted?:boolean, lifecycleStatus?:string, error?:string, keyId?:string}>} */ export async function verify(input, opts = {}) { const base = (opts.base || DEFAULT_BASE).replace(/\/$/, ''); + const mode = opts.verificationMode || 'live'; + if (mode !== 'live' && mode !== 'historical_snapshot') return { ok: false, error: 'verificationMode must be live or historical_snapshot' }; const text = typeof input === 'string' ? input : JSON.stringify(input); let root; try { root = lparse(text.trim()); } catch (e) { return { ok: false, error: 'invalid JSON — ' + e.message }; } @@ -105,17 +167,81 @@ export async function verify(input, opts = {}) { const keyId = (field(sig, 'key_id') || {}).v, sigB64 = (field(sig, 'sig') || {}).v; const alg = (field(sig, 'alg') || {}).v, canonName = (field(sig, 'canonicalization') || {}).v, ephN = field(sig, 'ephemeral_key'); if (!keyId || !sigB64) return { ok: false, error: 'signature block missing key_id or sig' }; - let ks = opts.jwks; - if (!ks) { try { ks = await fetchJwks(base); } catch (e) { return { ok: false, error: 'could not fetch the public key set' }; } } - if (!(keyId in ks)) return { ok: false, error: 'key_id ' + keyId + ' not in published JWKS (rotated?)', keyId }; - const stripped = { t: 'o', v: root.v.filter(p => p[0] !== 'signature' && p[0] !== 'anchor') }; - const msg = new TextEncoder().encode(canon(stripped)); - let ok = false; - try { ok = await ed.verifyAsync(b64u(sigB64), msg, b64u(ks[keyId])); } - catch (e) { return { ok: false, error: 'verify error — ' + e.message, keyId }; } + if (alg !== 'Ed25519' || canonName !== 'json-sorted-compact') return { + ok: false, cryptoValid: false, signerAccepted: false, lifecycleStatus: 'unknown', + error: 'unsupported signature metadata', keyId, + }; + let registry = null, registryError = null, registrySource = 'key-bytes-only', sourceAuthenticated = false; + if (opts.lifecycleRegistry) { + try { registry = await validateLifecycleRegistry(opts.lifecycleRegistry, opts); + registrySource = 'out-of-band'; sourceAuthenticated = opts.registrySourceAuthenticated === true; + } catch (e) { registryError = e; } + } else if (!Object.prototype.hasOwnProperty.call(opts, 'jwks')) { + try { if (mode === 'historical_snapshot') throw new Error('historical_snapshot requires an explicitly supplied pinned registry'); + registry = await fetchRegistry(base, opts); + const isDefaultOrigin = base === DEFAULT_BASE; + registrySource = isDefaultOrigin ? 'current-domain-https' : 'caller-configured-network-origin'; + sourceAuthenticated = opts.registrySourceAuthenticated === true || isDefaultOrigin; + } catch (e) { registryError = e; } + } + const ks = registry ? registry.public_keys : (opts.jwks || {}); + if (!(keyId in ks)) return { ok: false, cryptoValid: false, signerAccepted: false, + lifecycleStatus: 'unknown', error: registryError ? 'could not validate the signing-key lifecycle registry' + : 'key_id ' + keyId + ' not in supplied key bytes', keyId }; + // Two anchor conventions coexist: some receipts sign the body BEFORE a timestamp anchor is + // attached (an RFC 3161 / DLT anchor is OF the signed hash, so it can only land after signing — + // e.g. /v1/answer, /v1/receipt), others sign WITH the anchor already inside the bytes (e.g. notary + // inclusion proofs). Try the strip-signature form first; if it fails and an `anchor` is present, + // retry with the anchor stripped too. Accepting either canonical form is not a weakening — a + // forgery still needs a valid Ed25519 signature over one of the two forms. + const drops = [['signature']]; + if (root.v.some(p => p[0] === 'anchor')) drops.push(['signature', 'anchor']); + let sigBytes, pk; + try { sigBytes = b64u(sigB64); pk = b64u(ks[keyId]); } + catch (e) { return { ok: false, cryptoValid: false, signerAccepted: false, + lifecycleStatus: 'unknown', error: 'invalid signature or public-key encoding — ' + e.message, keyId }; } + if (sigBytes.length !== 64) return { ok: false, cryptoValid: false, signerAccepted: false, + lifecycleStatus: 'unknown', error: 'signature must decode to 64 bytes', keyId }; + const keyBindingValid = pk.length === 32 && await keyIdFor(pk) === keyId; + if (!keyBindingValid) return { ok: false, cryptoValid: false, keyBindingValid: false, + signerAccepted: false, lifecycleStatus: 'unknown', error: 'public key does not derive key_id ' + keyId, keyId }; + let cryptoValid = false, lastErr = null, anchorAuthenticated = null; + for (const drop of drops) { + const msg = new TextEncoder().encode(canon({ t: 'o', v: root.v.filter(p => !drop.includes(p[0])) })); + try { if (await ed.verifyAsync(sigBytes, msg, pk)) { + cryptoValid = true; anchorAuthenticated = root.v.some(p => p[0] === 'anchor') ? !drop.includes('anchor') : null; break; + } } + catch (e) { lastErr = e; } + } + const policy = registry ? lifecyclePolicy(registry, keyId, mode) : { + status: 'unknown', accepted: false, reason: 'flat key bytes carry no lifecycle policy', + }; + const signerAccepted = !!policy.accepted && sourceAuthenticated, ok = cryptoValid && signerAccepted; + if (!cryptoValid && lastErr) return { ok: false, cryptoValid: false, keyBindingValid, + signerAccepted, lifecycleStatus: policy.status, lifecycle: policy, + error: 'verify error — ' + lastErr.message, keyId }; const vNode = field(root, 'verdict'); - return { ok, keyId, alg, canon: canonName, ephemeral: !!(ephN && ephN.v), - verdict: vNode ? (field(vNode, 'status') || {}).v : null, snapshot: (field(root, 'snapshot_id') || {}).v }; + // verdict is a {status:...} object on awareness verdicts but a plain string (the answer) on + // /v1/answer receipts — surface whichever is present. + const verdict = vNode ? (vNode.t === 's' ? vNode.v : (field(vNode, 'status') || {}).v) : null; + const revision = registry ? revisionContext(registry, opts) : { + mode, registryRevision: null, effectiveRegistryFloor: mode === 'live' ? COMPILED_MIN_REGISTRY_REVISION : 0, + historicalSnapshot: mode === 'historical_snapshot', + }; + const rollbackProtected = mode === 'live' && sourceAuthenticated + && Number.isInteger(opts.highestAuthenticatedRegistryRevision) && !!registry + && registry.registry_revision >= opts.highestAuthenticatedRegistryRevision; + return { ok, cryptoValid, keyBindingValid, signerAccepted, lifecycleStatus: policy.status, + policyAsOfAccepted: !!policy.policyAsOfAccepted, + lifecycle: policy, trustBasis: registry ? (opts.lifecycleRegistry ? 'out-of-band' + : (registrySource === 'current-domain-https' ? 'current-domain' : 'caller-configured-network-origin')) + : 'key-bytes-only', + registrySource, registrySourceAuthenticated: sourceAuthenticated, rollbackProtected, ...revision, + error: ok ? undefined : (!cryptoValid ? 'signature INVALID' + : (policy.accepted && !sourceAuthenticated + ? 'lifecycle registry source was not explicitly authenticated' : policy.reason)), + anchorAuthenticated, keyId, alg, canon: canonName, ephemeral: !!(ephN && ephN.v), + verdict, snapshot: (field(root, 'snapshot_id') || {}).v }; } /** @@ -124,7 +250,9 @@ export async function verify(input, opts = {}) { */ export async function verifyLive(base = DEFAULT_BASE, body = { robot: { class: 'aerial' }, location: { lat: 51.5, lon: -0.12 } }) { const b = base.replace(/\/$/, ''); - const r = await fetch(b + '/v1/awareness', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const r = await fetch(b + '/v1/awareness', { method: 'POST', redirect: 'error', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!r.ok) return { text: '', result: { ok: false, error: 'awareness HTTP ' + r.status } }; const text = await r.text(); const result = await verify(text, { base: b }); return { text, result }; diff --git a/clients/js/package.json b/clients/js/package.json index 3ddfb59..dfb0e82 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -1,60 +1,51 @@ { "name": "@dynamicfeed/verify", - "version": "1.0.1", - "description": "Independently verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses, in any JavaScript runtime. No account, no runtime trust in the issuer.", + "version": "1.0.2", + "description": "Verify Dynamic Feed DF-VERIFY/1 signature bytes and signing-key lifecycle policy.", "type": "module", "main": "index.js", - "module": "index.js", "types": "index.d.ts", "exports": { ".": { "types": "./index.d.ts", - "import": "./index.js" + "default": "./index.js" } }, "bin": { - "dynamicfeed-verify": "./cli.js" + "dynamicfeed-verify": "cli.js" + }, + "scripts": { + "test": "node tests/anchor.test.js" }, "files": [ "index.js", - "cli.js", "index.d.ts", + "cli.js", "README.md" ], - "scripts": { - "test": "node ../../tests/verify_vectors.mjs" - }, - "dependencies": { - "@noble/ed25519": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, "keywords": [ + "dynamic-feed", + "df-verify", "ed25519", - "signature-verification", - "data-provenance", - "offline-verification", - "verifiable-data", - "tamper-evident", + "verification", + "provenance", "attestation", - "mcp", - "ai-agents", - "dynamic-feed", - "df-verify" + "ai", + "agents" ], "homepage": "https://dynamicfeed.ai/standard", "repository": { "type": "git", - "url": "git+https://github.com/dynamicfeed/df-verify.git", - "directory": "clients/js" - }, - "bugs": { - "url": "https://github.com/dynamicfeed/df-verify/issues" + "url": "git+https://github.com/dynamicfeed/df-verify.git" }, "license": "MIT", - "sideEffects": false, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@noble/ed25519": "^2.0.0" + }, "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/clients/js/tests/anchor.test.js b/clients/js/tests/anchor.test.js new file mode 100644 index 0000000..0e360a5 --- /dev/null +++ b/clients/js/tests/anchor.test.js @@ -0,0 +1,131 @@ +// Offline regression tests for the anchor-convention fix (@dynamicfeed/verify). +// +// A /v1/answer receipt carries a top-level `anchor` block attached AFTER signing (an RFC 3161 / DLT +// anchor is a timestamp OF the signed hash, so it can only land post-signing). A verifier that +// strips only `signature` recomputes the wrong bytes and wrongly reports INVALID. These fixtures +// were captured live from dynamicfeed.ai (2026-07-09); the key df-ed25519-… is persistent. No net. +// +// Run: node clients/js/tests/anchor.test.js +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { validateLifecycleRegistry, verify } from '../index.js'; + +const FIX = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); +const lifecycleRegistry = JSON.parse(readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../../../SIGNING_KEY_LIFECYCLE.json'), 'utf8' +)); +const read = (n) => readFileSync(join(FIX, n), 'utf8'); +const jwks = (pkFile, kid) => ({ [kid]: read(pkFile).trim() }); +const kidOf = (text) => JSON.parse(text).signature.key_id; + +async function run() { + // 1) anchored /v1/answer must verify VALID (the previously-broken case) + const ans = read('answer_anchored.json'); + assert.ok(ans.includes('"anchor"'), 'fixture must carry an anchor block'); + const r1 = await verify(ans, { lifecycleRegistry }); + assert.ok(r1.cryptoValid, 'anchored /v1/answer signature math must verify: ' + JSON.stringify(r1)); + assert.ok(!r1.ok && r1.lifecycleStatus === 'compromised', + 'historical compromised-key fixture must fail overall lifecycle policy'); + assert.strictEqual(r1.anchorAuthenticated, false, + 'legacy post-signature anchor must be surfaced as unauthenticated'); + + // 2) no-anchor awareness must still verify VALID (no regression) + const aw = read('awareness_noanchor.json'); + assert.ok(!aw.includes('"anchor"'), 'fixture must have no anchor block'); + const r2 = await verify(aw, { lifecycleRegistry }); + assert.ok(r2.cryptoValid, 'no-anchor awareness signature math must verify: ' + JSON.stringify(r2)); + assert.ok(!r2.ok && r2.lifecycleStatus === 'compromised'); + assert.strictEqual(r2.anchorAuthenticated, null); + + // 3) body tamper (flip a digit before the signature block) must verify INVALID + const bad = ans.replace(/(\d)(?=[\s\S]*"signature")/, (d) => (d === '9' ? '8' : String(+d + 1))); + assert.notStrictEqual(bad, ans, 'tamper must change the text'); + const r3 = await verify(bad, { lifecycleRegistry }); + assert.ok(!r3.cryptoValid && !r3.ok, 'a tampered body must fail cryptographic verification'); + + // 4) live registry revisions are monotonic caller state, never a bundle/self-supplied floor. + for (const value of [undefined, false, 0, 1.5]) { + const badRegistry = structuredClone(lifecycleRegistry); + if (value === undefined) delete badRegistry.registry_revision; + else badRegistry.registry_revision = value; + await assert.rejects(() => validateLifecycleRegistry(badRegistry), /registry_revision/); + } + await validateLifecycleRegistry(lifecycleRegistry, { minimumRegistryRevision: 1 }); + await assert.rejects(() => validateLifecycleRegistry(lifecycleRegistry, { + minimumRegistryRevision: 2, + }), /below effective floor/); + + const malformedKey = structuredClone(lifecycleRegistry); + malformedKey.public_keys[malformedKey.active_key_id] = '!!!!'; + await assert.rejects(() => validateLifecycleRegistry(malformedKey), /base64url/); + const malformedPadding = structuredClone(lifecycleRegistry); + malformedPadding.public_keys[malformedPadding.active_key_id] = 'AA='; + await assert.rejects(() => validateLifecycleRegistry(malformedPadding), /base64url/); + const invalidCalendar = structuredClone(lifecycleRegistry); + invalidCalendar.updated_at = '2026-02-30T10:00:00Z'; + await assert.rejects(() => validateLifecycleRegistry(invalidCalendar), /invalid/); + const malformedSignature = JSON.parse(aw); + malformedSignature.signature.sig = '!!!!'; + const malformedResult = await verify(malformedSignature, { + lifecycleRegistry, registrySourceAuthenticated: true, + }); + assert.strictEqual(malformedResult.ok, false); + assert.strictEqual(malformedResult.cryptoValid, false); + assert.match(malformedResult.error, /encoding/); + + // 5) historical inspection is explicit and can never turn the overall result green. + const historical = await verify(aw, { lifecycleRegistry, verificationMode: 'historical_snapshot', + minimumRegistryRevision: 2, registrySourceAuthenticated: true }); + assert.strictEqual(historical.ok, false); + assert.strictEqual(historical.historicalSnapshot, true); + assert.strictEqual(historical.registryRevision, 1); + assert.strictEqual(historical.effectiveRegistryFloor, 0); + assert.strictEqual(historical.rollbackProtected, false); + + assert.strictEqual(r2.registryRevision, 1); + assert.strictEqual(r2.effectiveRegistryFloor, 1); + assert.strictEqual(r2.rollbackProtected, false, + 'no caller-retained authenticated revision state means future rollback protection is not claimed'); + + const previousFetch = globalThis.fetch; + try { + const keyId = JSON.parse(aw).signature.key_id; + const activeRegistry = structuredClone(lifecycleRegistry); + activeRegistry.active_key_id = keyId; + activeRegistry.public_keys = { [keyId]: lifecycleRegistry.public_keys[keyId] }; + const activeMetadata = activeRegistry.lifecycle[keyId]; + Object.assign(activeMetadata, { status: 'active', retired_at: null, compromised_after: null }); + activeRegistry.lifecycle = { [keyId]: activeMetadata }; + const supplied = await verify(aw, { lifecycleRegistry: activeRegistry }); + assert.strictEqual(supplied.cryptoValid, true); + assert.strictEqual(supplied.ok, false); + assert.match(supplied.error, /not explicitly authenticated/); + const suppliedAuthenticated = await verify(aw, { lifecycleRegistry: activeRegistry, + registrySourceAuthenticated: true }); + assert.strictEqual(suppliedAuthenticated.ok, true); + + globalThis.fetch = async () => ({ ok: true, text: async () => JSON.stringify(activeRegistry) }); + const custom = await verify(aw, { base: 'https://mirror.example', + highestAuthenticatedRegistryRevision: 1 }); + assert.strictEqual(custom.trustBasis, 'caller-configured-network-origin'); + assert.strictEqual(custom.registrySourceAuthenticated, false); + assert.strictEqual(custom.rollbackProtected, false); + assert.strictEqual(custom.ok, false); + const explicit = await verify(aw, { base: 'https://mirror.example', + highestAuthenticatedRegistryRevision: 1, registrySourceAuthenticated: true }); + assert.strictEqual(explicit.registrySourceAuthenticated, true); + assert.strictEqual(explicit.rollbackProtected, true); + assert.strictEqual(explicit.ok, true); + } finally { + globalThis.fetch = previousFetch; + } + + console.log('PASS — historical signature math valid, compromised lifecycle rejected, tamper caught'); +} + +run().catch((e) => { + console.error('FAIL —', e.message); + process.exit(1); +}); diff --git a/clients/js/tests/fixtures/answer_anchored.json b/clients/js/tests/fixtures/answer_anchored.json new file mode 100644 index 0000000..f966aa8 --- /dev/null +++ b/clients/js/tests/fixtures/answer_anchored.json @@ -0,0 +1 @@ +{"schema":"answer/v1","receipt_id":"ans_1cfb63d0e98f3769","generated_at":"2026-07-09T10:22:15Z","issuer":"dynamicfeed.ai","verdict":"outside_evidence_coverage","confidence":0.0,"confidence_rule":"answer-conf/1: supported/evidenced = min of check confidences over required checks; contradicted = max of check confidences over contradicting required checks; insufficient_evidence = 0.0; check confidence = min of fact.verification.confidence over its cited facts (fact-conf/1, the facts.py composite, every signal exposed). Weakest premise caps a conjunction; the best-evidenced counterexample sets a contradiction; no independence assumption is smuggled in by multiplying.","unrouted":["what is the current bitcoin block height?"],"coverage":{"schema":"answer-coverage/v1","router_version":"route/1","router_sha256":"3aeca61003008b23db886d2ffdf857b38b413eff12daef4a8986bedc4a16adff","families":[{"route_id":"software.version","ask":["is the latest python 3.13?","what is the latest version of node?"],"tool":"software_version"},{"route_id":"market.status","ask":["is the NASDAQ open?","the US market is open"],"tool":"market_hours"},{"route_id":"weather.city","ask":["what's the weather in Sydney?"],"tool":"current_weather"},{"route_id":"air_quality.city","ask":["what's the air quality in Delhi?"],"tool":"air_quality"},{"route_id":"treasury.yield","ask":["what is the 10 year treasury yield?"],"tool":"treasury_yields"}],"not_covered":["opinions and predictions","historical states","asset prices in question mode","any subject without a named live source in the tool catalog"],"refusal_contract":"questions outside these families return a SIGNED outside_evidence_coverage refusal; this endpoint never guesses","checks_mode":"structured checks reach ALL live tools directly and skip question parsing entirely; see GET /v1/answer usage + GET /tools.json"},"note":"No deterministic route from this text to a live evidence source exists in route/1. Dynamic Feed answers only what it can evidence; it never guesses. Structured checks reach all live tools directly (see GET /v1/answer).","principle":"signed proves integrity, not truth; verified is reserved for 2+ independent sources agreeing on this reading while fresh","advisory":true,"disclaimer":"ADVISORY EVIDENCE ONLY: a signed, timestamped record of what the named public sources reported at issue time. Not a certification and not a guarantee the underlying claim is true in the world. Tamper-evident, not tamper-proof.","served_by":{"provider":"Dynamic Feed","url":"https://dynamicfeed.ai","verify":"https://dynamicfeed.ai/.well-known/keys","facts":"https://dynamicfeed.ai/v1/facts","mcp":"https://dynamicfeed.ai/mcp","docs":"https://dynamicfeed.ai/llms.txt"},"signature":{"alg":"Ed25519","key_id":"df-ed25519-4cb32e72f333","canonicalization":"json-sorted-compact","sig":"Szc-rsKecelnsQRBNw-fyWMo9LDpZPf97vvlITSh4Ha6_PLAXVw6Km5J0G4Kr18zyXiaDPXOWDhjesXMUYYuAA=="},"anchor":{"status":"self_anchor","digest_sha256":"a0776acaa675c6717d226dd8f1da3633469675b0dfab349885cd1212512882c0","how":"save the receipt without its `anchor` field as canonical JSON (keys sorted, compact separators, UTF-8), then: openssl ts -query -data receipt.json -sha256 -cert | curl -s -H 'Content-Type: application/timestamp-query' --data-binary @- https://freetsa.org/tsr > receipt.tsr","note":"any holder can obtain RFC 3161 tokens from any public TSA at any time; Dynamic Feed is not a dependency for proof-of-when. Inline anchoring is available with an API key (anchor: true)."}} diff --git a/clients/js/tests/fixtures/answer_pubkey.txt b/clients/js/tests/fixtures/answer_pubkey.txt new file mode 100644 index 0000000..6af663e --- /dev/null +++ b/clients/js/tests/fixtures/answer_pubkey.txt @@ -0,0 +1 @@ +O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk= diff --git a/clients/js/tests/fixtures/awareness_noanchor.json b/clients/js/tests/fixtures/awareness_noanchor.json new file mode 100644 index 0000000..2b3a98a --- /dev/null +++ b/clients/js/tests/fixtures/awareness_noanchor.json @@ -0,0 +1 @@ +{"snapshot_id":"0077b6e019464cfe9c546bf639073e55","issued_at":"2026-07-09T10:22:16Z","issuer":"dynamicfeed.ai","schema":"awareness/v1","robot":{"id":null,"class":"aerial"},"location":{"lat":51.5,"lon":-0.12,"alt_m":null},"verdict":{"status":"go","reasons":["all checked conditions within limits"],"advisory":"Clear to proceed, all checked conditions within limits."},"facts":[{"id":"wx.wind_kmh","domain":"weather","value":5.8,"unit":"km/h","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.precip_mm","domain":"weather","value":0.0,"unit":"mm","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.temp_c","domain":"weather","value":28.5,"unit":"°C","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.conditions","domain":"weather","value":"Mainly clear","unit":null,"observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"sw.kp_index","domain":"space","value":2.33,"unit":"Kp","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"sw.storm_g","domain":"space","value":0,"unit":"G-scale","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"sw.storm_s","domain":"space","value":0,"unit":"S-scale","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"aq.us_aqi","domain":"air_quality","value":35,"unit":"US AQI","observed_at":"2026-07-09T11:00+01:00","age_s":1336,"source":"Open-Meteo Air Quality","source_url":"https://open-meteo.com","confidence":0.95,"stale":false,"category":"Good"}],"freshness":{"min_age_s":436,"max_age_s":15736,"stalest_field":"sw.kp_index"},"degraded":false,"degraded_sources":[],"note":"awareness/v1 (Rung A+B): signed verdict + grounded facts over live feeds; never blocks (hard deadline → degraded; fail-safe floors to caution, incl. on stale safety data). Verify the Ed25519 signature against GET /.well-known/keys (see scripts/verify_awareness.py). Public chain-anchoring of the signature is Rung C.","advisory":true,"disclaimer":"ADVISORY EVIDENCE ONLY: a signed, timestamped snapshot of what public data showed at issue time. This is NOT a safety certification, safety system or safety function, and NOT a guarantee that acting is safe. Your control stack owns every action and must apply its own safety logic. The signature proves this snapshot's existence and integrity, not the safety of any decision made from it.","signature":{"alg":"Ed25519","key_id":"df-ed25519-4cb32e72f333","canonicalization":"json-sorted-compact","sig":"b2XlAHX2TASu-2hErXorzs3_xZMlbKOpK8Xw8pA06db4XPwIR-AOQpNdFhm0zwR_v-VZCz0zG8RcqQQsiQNKCg=="}} diff --git a/clients/js/tests/fixtures/awareness_pubkey.txt b/clients/js/tests/fixtures/awareness_pubkey.txt new file mode 100644 index 0000000..6af663e --- /dev/null +++ b/clients/js/tests/fixtures/awareness_pubkey.txt @@ -0,0 +1 @@ +O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk= diff --git a/clients/python/.gitignore b/clients/python/.gitignore new file mode 100644 index 0000000..5b773ab --- /dev/null +++ b/clients/python/.gitignore @@ -0,0 +1,5 @@ +dist/ +build/ +*.egg-info/ +__pycache__/ +.pytest_cache/ diff --git a/clients/python/README.md b/clients/python/README.md index 568f7de..7663302 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -1,13 +1,17 @@ # dynamicfeed-verify -Verify [Dynamic Feed](https://dynamicfeed.ai) **DF-VERIFY/1** Ed25519-signed responses independently, in one line. No account, no dependency on Dynamic Feed at runtime beyond fetching the public key. You can verify, even against us. +Verify [Dynamic Feed](https://dynamicfeed.ai) **DF-VERIFY/1** signed bytes and signing-key lifecycle policy in one line. The default policy is fetched from the current HTTPS domain; origin-independent verification requires an out-of-band pinned registry. Reference implementation of the [DF-VERIFY/1 standard](https://dynamicfeed.ai/standard). ## Install +`1.0.3` is the hardened source target in this branch. Until the security advisory records that it +has been published and clean-install tested, install from the reviewed source commit rather than +assuming the package index is current: + ```bash -pip install dynamicfeed-verify +python -m pip install -e clients/python ``` ## Use @@ -24,8 +28,15 @@ result = verify(signed_response) if not result["ok"]: raise RuntimeError(result["error"]) -# 3) verify fully offline if you already have the JWKS -result = verify(signed_response, jwks={"df-ed25519-…": ""}) +# 3) verify fully offline using an out-of-band pinned df-signing-key-registry/v1 document +result = verify( + signed_response, + lifecycle_registry=pinned_registry, + registry_source_authenticated=True, +) + +# A legacy flat key map reports crypto_valid only and deliberately leaves ok=False. +crypto_only = verify(signed_response, jwks={"df-ed25519-…": ""}) ``` ## CLI @@ -39,10 +50,21 @@ dynamicfeed-verify - < response.json # verify a saved signed response A signed response carries a `signature` block (`alg`, `key_id`, `canonicalization`, `sig`). Verification: -1. Drop the `signature` field; keep the rest as the payload. -2. Canonicalize: JSON, keys sorted recursively, compact separators (`,` `:`), UTF-8. Equivalent to `json.dumps(payload, sort_keys=True, separators=(",", ":"))`. -3. Fetch the public key from `https://dynamicfeed.ai/.well-known/keys` and look up `signature.key_id`. -4. Verify the Ed25519 signature over the canonical bytes. Change one byte → it fails. +1. Require the exact `Ed25519` / `json-sorted-compact` signature metadata. +2. Preserve both frozen anchor conventions: try the document without `signature` first, then (only + if needed) without both `signature` and `anchor`. The result reports + `anchor_authenticated=True`, `False`, or `None`; a legacy attached anchor is not authenticated by + signature validity. +3. Canonicalize — JSON, keys sorted recursively, compact separators (`,` `:`), UTF-8. Equivalent to `json.dumps(payload, sort_keys=True, separators=(",", ":"))`. +4. Resolve the key and lifecycle state from `/.well-known/signing-key-registry.json` or a pinned copy. +5. Verify Ed25519 signature mathematics and bind the public key bytes to `key_id`. +6. Return `ok=True` only when cryptography and lifecycle policy pass. Compromised-key bytes remain inspectable through `crypto_valid`, but never turn green from signed `issued_at` or bundle metadata. + +Live mode enforces the compiled revision floor plus `minimum_registry_revision` and the caller's +persisted `highest_authenticated_registry_revision`. A registry's self-declared revision is not +anti-rollback. Caller-supplied registries and custom network origins require the caller to set +`registry_source_authenticated=True`; otherwise signature mathematics may pass but `ok` remains +false. Redirects fail closed. `historical_snapshot` is explicit and always non-actionable. Full specification: **https://dynamicfeed.ai/standard** diff --git a/clients/python/dynamicfeed_verify/__init__.py b/clients/python/dynamicfeed_verify/__init__.py index 99ac5a2..ff2b96d 100644 --- a/clients/python/dynamicfeed_verify/__init__.py +++ b/clients/python/dynamicfeed_verify/__init__.py @@ -2,9 +2,10 @@ dynamicfeed-verify — verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses, independently. A signed response carries a top-level ``signature`` block. This library reproduces the DF-VERIFY/1 -canonical form (JSON, keys sorted recursively, compact separators), fetches the public key published -at ``/.well-known/keys``, and verifies the detached Ed25519 signature. If it verifies, the -response provably came from the issuer and has not been altered — checkable by anyone, even against us. +canonical form (JSON, keys sorted recursively, compact separators), verifies the Ed25519 signature, +and separately applies the signing-key lifecycle policy at +``/.well-known/signing-key-registry.json``. A flat key map can report signature mathematics, +but cannot make the overall ``ok`` policy verdict pass. Spec: https://dynamicfeed.ai/standard @@ -16,27 +17,178 @@ from __future__ import annotations import base64 +import hashlib import json +import re import urllib.request +from datetime import datetime, timezone +from typing import Optional from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey __version__ = "1.0.3" DEFAULT_BASE = "https://dynamicfeed.ai" +COMPILED_MIN_REGISTRY_REVISION = 1 +_UA = {"User-Agent": f"dynamicfeed-verify/{__version__} (+https://dynamicfeed.ai)"} -# Pinned Dynamic Feed public keys (offline fallback; Principle 1, the courtroom test): receipts -# stay verifiable even if dynamicfeed.ai is unreachable or ceases to exist. Only ever appended -# to. The live set at /.well-known/keys takes precedence when reachable. -KNOWN_KEYS = { - "df-ed25519-4cb32e72f333": "O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk=", -} -# Send an explicit, honest User-Agent: some WAFs (e.g. Cloudflare) 403 the default "Python-urllib". -_UA = f"dynamicfeed-verify/{__version__} (+https://dynamicfeed.ai)" + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Reject redirects so an authenticated registry origin cannot be swapped.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError(f"redirect rejected while fetching trust material ({code})") + + +_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect) + + +def _urlopen_no_redirect(request, timeout): + return _NO_REDIRECT_OPENER.open(request, timeout=timeout) def _b64d(s: str) -> bytes: - """Decode base64url, tolerating missing padding.""" - return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + """Strictly decode padded or unpadded base64url.""" + padding = len(s) - len(s.rstrip("=")) if isinstance(s, str) else 0 + if not isinstance(s, str) or not re.fullmatch(r"[A-Za-z0-9_-]+={0,2}", s) \ + or len(s.rstrip("=")) % 4 == 1 or (padding and len(s) % 4 != 0): + raise ValueError("invalid base64url") + return base64.b64decode(s + "=" * (-len(s) % 4), altchars=b"-_", validate=True) + + +def _unique_object(pairs): + out = {} + for key, value in pairs: + if key in out: + raise ValueError(f"duplicate JSON object key: {key}") + out[key] = value + return out + + +def _strict_loads(raw: str): + return json.loads(raw, object_pairs_hook=_unique_object, + parse_constant=lambda value: (_ for _ in ()).throw( + ValueError(f"non-standard JSON constant: {value}"))) + + +def _utc(value, field): + match = (re.fullmatch( + r"(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?Z", + value, + ) if isinstance(value, str) else None) + if match is None: + raise ValueError(f"{field} must be RFC3339 UTC") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ValueError(f"{field} is not a valid UTC timestamp") from exc + expected = tuple(int(part) for part in match.groups()[:6]) + actual = (parsed.year, parsed.month, parsed.day, parsed.hour, parsed.minute, parsed.second) + if parsed.tzinfo != timezone.utc or actual != expected: + raise ValueError(f"{field} is not a valid UTC timestamp") + return parsed + + +def _revision_context(raw: dict, *, mode: str = "live", + minimum_registry_revision: Optional[int] = None, + highest_authenticated_registry_revision: Optional[int] = None) -> dict: + if mode not in {"live", "historical_snapshot"}: + raise ValueError("verification mode must be live or historical_snapshot") + revision = raw.get("registry_revision") + if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1: + raise ValueError("registry_revision must be a positive integer") + for name, value in (("minimum_registry_revision", minimum_registry_revision), + ("highest_authenticated_registry_revision", + highest_authenticated_registry_revision)): + if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value < 1): + raise ValueError(f"{name} must be a positive integer") + floor = (0 if mode == "historical_snapshot" else max( + COMPILED_MIN_REGISTRY_REVISION, + minimum_registry_revision or COMPILED_MIN_REGISTRY_REVISION, + highest_authenticated_registry_revision or 0, + )) + if mode == "live" and revision < floor: + raise ValueError(f"registry revision {revision} is below effective floor {floor}") + return {"mode": mode, "registry_revision": revision, "effective_registry_floor": floor, + "historical_snapshot": mode == "historical_snapshot"} + + +def validate_lifecycle_registry(raw: dict, *, mode: str = "live", + minimum_registry_revision: Optional[int] = None, + highest_authenticated_registry_revision: Optional[int] = None) -> dict: + if not isinstance(raw, dict) or raw.get("schema") != "df-signing-key-registry/v1" \ + or raw.get("issuer") != "dynamicfeed.ai": + raise ValueError("invalid signing-key registry identity") + _revision_context( + raw, mode=mode, minimum_registry_revision=minimum_registry_revision, + highest_authenticated_registry_revision=highest_authenticated_registry_revision, + ) + _utc(raw.get("updated_at"), "updated_at") + keys, lifecycle, active_id = raw.get("public_keys"), raw.get("lifecycle"), raw.get("active_key_id") + if not isinstance(keys, dict) or not keys or not isinstance(lifecycle, dict) \ + or set(keys) != set(lifecycle) or active_id not in keys: + raise ValueError("incomplete signing-key registry") + active = [] + for key_id, encoded in keys.items(): + public_raw = _b64d(encoded) + fingerprint = hashlib.sha256(public_raw).hexdigest() + if len(public_raw) != 32 or key_id != "df-ed25519-" + fingerprint[:12]: + raise ValueError(f"public key does not derive {key_id}") + meta = lifecycle[key_id] + if not isinstance(meta, dict) or meta.get("alg") != "Ed25519" \ + or meta.get("use") != "receipt-signing" \ + or meta.get("fingerprint_sha256") != fingerprint \ + or meta.get("public_key_retained") is not True \ + or meta.get("status") not in {"active", "retired", "compromised"}: + raise ValueError(f"invalid lifecycle entry for {key_id}") + first, active_from = _utc(meta.get("first_used_at"), f"{key_id}.first_used_at"), \ + _utc(meta.get("active_from"), f"{key_id}.active_from") + if first > active_from: + raise ValueError(f"invalid first-use interval for {key_id}") + if meta["status"] == "active": + active.append(key_id) + if meta.get("retired_at") is not None or meta.get("compromised_after") is not None: + raise ValueError(f"active key {key_id} carries retirement metadata") + else: + retired = _utc(meta.get("retired_at"), f"{key_id}.retired_at") + if active_from > retired: + raise ValueError(f"invalid retirement interval for {key_id}") + if meta["status"] == "compromised": + if _utc(meta.get("compromised_after"), f"{key_id}.compromised_after") > retired: + raise ValueError(f"invalid compromise boundary for {key_id}") + elif meta.get("compromised_after") is not None: + raise ValueError(f"retired key {key_id} carries compromise metadata") + if active != [active_id] or (raw.get("authority") or {}).get("independent_registry_root") is not False: + raise ValueError("invalid registry active key or authority classification") + policy = raw.get("verification_policy") or {} + for name in ("cryptographic_validity_separate_from_lifecycle", + "retired_keys_verify_historical_bytes", + "compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp", + "receipt_issued_at_alone_is_not_independent_time_evidence"): + if policy.get(name) is not True: + raise ValueError("incomplete registry verification policy") + return raw + + +def _policy(registry, key_id, mode="live"): + meta = (registry.get("lifecycle") or {}).get(key_id) + if not meta: + return "unknown", False, False, "key absent from lifecycle registry" + status = meta["status"] + if mode == "historical_snapshot": + if status == "compromised": + return status, False, False, ("compromised key; no independently verified " + "signature-inclusive pre-boundary commitment") + policy_as_of = status in {"active", "retired"} + return status, False, policy_as_of, ( + "historical snapshot is non-live and non-actionable; policy-as-of is reported separately" + ) + if status == "active": + accepted = registry.get("active_key_id") == key_id + return status, accepted, False, "active lifecycle key" + if status == "retired": + return status, False, False, "retired key is not accepted for live verification" + return status, False, False, ("compromised key; no independently verified signature-inclusive " + "pre-boundary commitment") def canonical(payload: dict) -> bytes: @@ -46,55 +198,192 @@ def canonical(payload: dict) -> bytes: def fetch_keys(base: str = DEFAULT_BASE, timeout: float = 20) -> dict: - """Fetch the JWKS-style public-key map: ``{key_id: base64url(Ed25519 public key)}``. - Falls back to the pinned ``KNOWN_KEYS`` if the live set is unreachable, so verification - works offline and outlives the domain.""" - try: - req = urllib.request.Request(base.rstrip("/") + "/.well-known/keys", headers={"User-Agent": _UA}) - with urllib.request.urlopen(req, timeout=timeout) as r: - live = json.load(r) - merged = dict(KNOWN_KEYS) - merged.update({k: v for k, v in live.items() if isinstance(v, str)}) - return merged - except Exception: - return dict(KNOWN_KEYS) + """Fetch the JWKS-style public-key map: ``{key_id: base64url(Ed25519 public key)}``.""" + req = urllib.request.Request(base.rstrip("/") + "/.well-known/keys", headers=_UA) + with _urlopen_no_redirect(req, timeout) as r: + return json.load(r) + + +def fetch_lifecycle_registry(base: str = DEFAULT_BASE, timeout: float = 20, *, + minimum_registry_revision: Optional[int] = None, + highest_authenticated_registry_revision: Optional[int] = None) -> dict: + """Fetch and strictly validate the current-domain lifecycle registry without cache reuse.""" + req = urllib.request.Request( + base.rstrip("/") + "/.well-known/signing-key-registry.json", + headers={**_UA, "Cache-Control": "no-cache"}, + ) + with _urlopen_no_redirect(req, timeout) as response: + return validate_lifecycle_registry( + _strict_loads(response.read().decode("utf-8")), mode="live", + minimum_registry_revision=minimum_registry_revision, + highest_authenticated_registry_revision=highest_authenticated_registry_revision, + ) -def verify(envelope: dict, jwks: dict | None = None, base: str = DEFAULT_BASE) -> dict: +def verify(envelope: dict, jwks: Optional[dict] = None, base: str = DEFAULT_BASE, + lifecycle_registry: Optional[dict] = None, *, verification_mode: str = "live", + minimum_registry_revision: Optional[int] = None, + highest_authenticated_registry_revision: Optional[int] = None, + registry_source_authenticated: bool = False) -> dict: """Verify a DF-VERIFY/1 signed response. - Returns ``{"ok": bool, "key_id"?, "verdict"?, "snapshot_id"?, "ephemeral"?, "error"?}``. - Pass ``jwks`` to verify fully offline; otherwise the public key is fetched from ``base``. + Returns a structured result including ``crypto_valid``, ``signer_accepted``, and + ``anchor_authenticated``. The last field is ``True`` when an attached ``anchor`` was inside + the signed bytes, ``False`` for the frozen legacy post-signature attachment convention, and + ``None`` when no anchor is present or no signature form passed. + Pass ``lifecycle_registry`` for out-of-band offline policy. Live mode accepts only the active + key and enforces the compiled/caller/retained revision floor. ``historical_snapshot`` must be + selected explicitly and never makes ``ok`` true. Callers that persist an authenticated highest + revision should pass it back atomically; otherwise ``rollback_protected`` remains false for + future registry updates. Caller-supplied registries and custom network origins require + ``registry_source_authenticated=True`` before live ``ok`` can pass. Redirects fail closed. + ``jwks`` is retained as a legacy crypto-only input: it can set + ``crypto_valid`` but never makes ``ok`` true. """ - sig = (envelope or {}).get("signature") or {} + if verification_mode not in {"live", "historical_snapshot"}: + return {"ok": False, "error": "verification_mode must be live or historical_snapshot"} + if not isinstance(envelope, dict): + return {"ok": False, "crypto_valid": False, + "error": "expected a top-level JSON object"} + sig = envelope.get("signature") or {} + if not isinstance(sig, dict): + return {"ok": False, "crypto_valid": False, + "error": "signature must be a JSON object"} kid, sig_b64 = sig.get("key_id"), sig.get("sig") if not kid or not sig_b64: return {"ok": False, "error": "no signature block (need signature.key_id + signature.sig)"} - payload = {k: v for k, v in envelope.items() if k not in ("signature", "anchor")} - # `anchor` is added AFTER signing (so any holder can independently RFC 3161 timestamp - # the canonical body); it is never part of the signed bytes and MUST be ignored here. - keys = jwks if jwks is not None else fetch_keys(base) + if sig.get("alg") != "Ed25519" or sig.get("canonicalization") != "json-sorted-compact": + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": kid, + "error": "unsupported signature metadata"} + stripped = {k: v for k, v in envelope.items() if k != "signature"} + registry = None + registry_source = "key-bytes-only" + source_authenticated = False + if lifecycle_registry is not None: + try: + registry = validate_lifecycle_registry( + lifecycle_registry, mode=verification_mode, + minimum_registry_revision=minimum_registry_revision, + highest_authenticated_registry_revision=highest_authenticated_registry_revision, + ) + registry_source = "out-of-band" + source_authenticated = bool(registry_source_authenticated) + except Exception as exc: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": kid, + "error": f"invalid lifecycle registry: {exc}"} + elif jwks is None: + try: + if verification_mode == "historical_snapshot": + raise ValueError("historical_snapshot mode requires an explicitly supplied pinned registry") + registry = fetch_lifecycle_registry( + base, minimum_registry_revision=minimum_registry_revision, + highest_authenticated_registry_revision=highest_authenticated_registry_revision, + ) + is_default_origin = base.rstrip("/") == DEFAULT_BASE + registry_source = ("current-domain-https" if is_default_origin + else "caller-configured-network-origin") + source_authenticated = bool(registry_source_authenticated) or is_default_origin + except Exception as exc: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": kid, + "error": f"could not fetch/validate lifecycle registry: {exc}"} + keys = registry["public_keys"] if registry is not None else (jwks or {}) if kid not in keys: - return {"ok": False, "key_id": kid, "error": f"key_id {kid} not in JWKS (rotated or ephemeral)"} + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": kid, + "error": f"key_id {kid} not in supplied trust material"} try: - Ed25519PublicKey.from_public_bytes(_b64d(keys[kid])).verify(_b64d(sig_b64), canonical(payload)) - except Exception as e: # noqa: BLE001 — any failure means it did not verify - return {"ok": False, "key_id": kid, "error": f"signature INVALID: {e}"} + public_raw = _b64d(keys[kid]) + sig_bytes = _b64d(sig_b64) + except Exception as exc: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": kid, + "error": f"invalid signature or public-key encoding: {exc}"} + if len(public_raw) != 32 or kid != "df-ed25519-" + hashlib.sha256(public_raw).hexdigest()[:12]: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": kid, + "error": "public key does not derive declared key_id"} + if len(sig_bytes) != 64: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": kid, + "error": "signature must decode to 64 bytes"} + pub = Ed25519PublicKey.from_public_bytes(public_raw) + # Two anchor conventions coexist. Some receipts sign the body BEFORE the timestamp anchor is + # attached — an RFC 3161 / DLT anchor is a timestamp OF the signed hash, so it can only land + # after signing (e.g. /v1/answer, /v1/receipt). Others sign WITH the anchor already inside the + # bytes (e.g. notary inclusion proofs). Try the strip-signature form first; if it fails and an + # anchor is present, retry with the anchor stripped too. Accepting either form is not a + # weakening: a forgery still needs a valid Ed25519 signature over one of the two canonical forms. + candidates = [(stripped, True if "anchor" in stripped else None)] + if "anchor" in stripped: + candidates.append(({k: v for k, v in stripped.items() if k != "anchor"}, False)) + last_err = None + anchor_authenticated = None + for payload, anchor_scope in candidates: + try: + pub.verify(sig_bytes, canonical(payload)) + anchor_authenticated = anchor_scope + break + except Exception as e: # noqa: BLE001 — any failure means this form did not verify + last_err = e + else: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown" if registry is None else _policy( + registry, kid, verification_mode)[0], + "key_id": kid, "error": f"signature INVALID: {last_err}"} + status, accepted, policy_as_of, detail = _policy(registry, kid, verification_mode) \ + if registry is not None else ( + "unknown", False, False, "flat key bytes carry no lifecycle policy" + ) + if accepted and not source_authenticated: + accepted = False + detail = "lifecycle registry source was not explicitly authenticated" + revision_context = (_revision_context( + registry, mode=verification_mode, minimum_registry_revision=minimum_registry_revision, + highest_authenticated_registry_revision=highest_authenticated_registry_revision, + ) if registry is not None else { + "mode": verification_mode, "registry_revision": None, + "effective_registry_floor": (COMPILED_MIN_REGISTRY_REVISION + if verification_mode == "live" else 0), + "historical_snapshot": verification_mode == "historical_snapshot", + }) + rollback_protected = bool( + verification_mode == "live" and source_authenticated + and highest_authenticated_registry_revision is not None + and registry is not None + and registry["registry_revision"] >= highest_authenticated_registry_revision + ) + # verdict is a {"status": ...} dict on awareness verdicts but a plain string (the answer) on + # /v1/answer receipts — surface whichever is present without assuming a shape. v = envelope.get("verdict") return { - "ok": True, "key_id": kid, "ephemeral": bool(sig.get("ephemeral_key")), - # awareness envelopes carry {"status": ...}; answer/v1 receipts carry a plain string + "ok": bool(accepted), "crypto_valid": True, "signer_accepted": bool(accepted), + "policy_as_of_accepted": bool(policy_as_of), + "lifecycle_status": status, "trust_basis": "out-of-band" if lifecycle_registry is not None + else ("current-domain" if registry_source == "current-domain-https" + else ("caller-configured-network-origin" if registry is not None + else "key-bytes-only")), + "registry_source": registry_source, + "registry_source_authenticated": source_authenticated, + "rollback_protected": rollback_protected, + **revision_context, + "anchor_authenticated": anchor_authenticated, + "error": None if accepted else detail, + "key_id": kid, "ephemeral": bool(sig.get("ephemeral_key")), "verdict": v.get("status") if isinstance(v, dict) else v, "snapshot_id": envelope.get("snapshot_id"), } -def verify_live(base: str = DEFAULT_BASE, robot: dict | None = None, location: dict | None = None): +def verify_live(base: str = DEFAULT_BASE, robot: Optional[dict] = None, + location: Optional[dict] = None): """Fetch a fresh signed awareness verdict from ``base`` and verify it. Returns ``(envelope, result)``.""" body = {"robot": robot or {"class": "aerial"}, "location": location or {"lat": 51.5, "lon": -0.12}} req = urllib.request.Request( base.rstrip("/") + "/v1/awareness", - data=json.dumps(body).encode(), headers={"Content-Type": "application/json", "User-Agent": _UA}) - with urllib.request.urlopen(req, timeout=25) as r: - env = json.load(r) + data=json.dumps(body).encode(), headers={"Content-Type": "application/json", **_UA}) + with _urlopen_no_redirect(req, 25) as r: + env = _strict_loads(r.read().decode("utf-8")) return env, verify(env, base=base) diff --git a/clients/python/dynamicfeed_verify/__main__.py b/clients/python/dynamicfeed_verify/__main__.py index e645b3d..90a19cc 100644 --- a/clients/python/dynamicfeed_verify/__main__.py +++ b/clients/python/dynamicfeed_verify/__main__.py @@ -4,7 +4,7 @@ import json import sys -from . import DEFAULT_BASE, verify, verify_live +from . import DEFAULT_BASE, _strict_loads, verify, verify_live def main() -> int: @@ -12,11 +12,11 @@ def main() -> int: if args and args[0] in ("-h", "--help"): print("usage:\n" " dynamicfeed-verify [BASE_URL] fetch a live signed verdict and verify it\n" - " dynamicfeed-verify - < response.json verify a saved signed response (key still fetched)\n" + " dynamicfeed-verify - [BASE_URL] < response.json verify saved bytes with lifecycle policy\n" " default BASE_URL = https://dynamicfeed.ai · spec: https://dynamicfeed.ai/standard") return 0 if args and args[0] == "-": - env = json.load(sys.stdin) + env = _strict_loads(sys.stdin.read()) base = args[1].rstrip("/") if len(args) > 1 else DEFAULT_BASE res = verify(env, base=base) else: @@ -31,8 +31,12 @@ def main() -> int: extra += f" · snapshot={res['snapshot_id']}" if res.get("ephemeral"): extra += " · EPHEMERAL key" - print(f"✅ VALID — key={res['key_id']}{extra}") + print(f"✅ POLICY ACCEPTED — signature valid · lifecycle={res.get('lifecycle_status')} · key={res['key_id']}{extra}") return 0 + if res.get("crypto_valid"): + print(f"△ CRYPTOGRAPHICALLY VALID, LIFECYCLE REJECTED — key={res.get('key_id')} · " + f"status={res.get('lifecycle_status', 'unknown')} · {res.get('error')}") + return 1 print(f"✗ INVALID — {res.get('error')}") return 1 diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 4c6653d..f0620ce 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -5,27 +5,25 @@ build-backend = "hatchling.build" [project] name = "dynamicfeed-verify" version = "1.0.3" -description = "Independently verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses. Offline, zero trust in the issuer." +description = "Verify Dynamic Feed DF-VERIFY/1 signature bytes and signing-key lifecycle policy." readme = "README.md" requires-python = ">=3.8" license = { text = "MIT" } -authors = [{ name = "Dynamic Feed", email = "hello@dynamicfeed.ai" }] -keywords = ["ed25519", "signature-verification", "data-provenance", "offline-verification", "verifiable-data", "tamper-evident", "ai-agents", "mcp", "df-verify"] -dependencies = ["cryptography>=3.4"] - +authors = [{ name = "Dynamic Feed" }] +keywords = ["dynamic-feed", "ed25519", "verification", "provenance", "attestation", "df-verify", "ai", "agents"] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Topic :: Security :: Cryptography", - "Topic :: Software Development :: Libraries", - "Operating System :: OS Independent", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Topic :: Security :: Cryptography", ] +dependencies = ["cryptography>=41"] [project.urls] -Homepage = "https://dynamicfeed.ai/standard" +Homepage = "https://dynamicfeed.ai" +Standard = "https://dynamicfeed.ai/standard" Source = "https://github.com/dynamicfeed/df-verify" -Issues = "https://github.com/dynamicfeed/df-verify/issues" [project.scripts] dynamicfeed-verify = "dynamicfeed_verify.__main__:main" diff --git a/clients/python/tests/fixtures/answer_anchored.json b/clients/python/tests/fixtures/answer_anchored.json new file mode 100644 index 0000000..f966aa8 --- /dev/null +++ b/clients/python/tests/fixtures/answer_anchored.json @@ -0,0 +1 @@ +{"schema":"answer/v1","receipt_id":"ans_1cfb63d0e98f3769","generated_at":"2026-07-09T10:22:15Z","issuer":"dynamicfeed.ai","verdict":"outside_evidence_coverage","confidence":0.0,"confidence_rule":"answer-conf/1: supported/evidenced = min of check confidences over required checks; contradicted = max of check confidences over contradicting required checks; insufficient_evidence = 0.0; check confidence = min of fact.verification.confidence over its cited facts (fact-conf/1, the facts.py composite, every signal exposed). Weakest premise caps a conjunction; the best-evidenced counterexample sets a contradiction; no independence assumption is smuggled in by multiplying.","unrouted":["what is the current bitcoin block height?"],"coverage":{"schema":"answer-coverage/v1","router_version":"route/1","router_sha256":"3aeca61003008b23db886d2ffdf857b38b413eff12daef4a8986bedc4a16adff","families":[{"route_id":"software.version","ask":["is the latest python 3.13?","what is the latest version of node?"],"tool":"software_version"},{"route_id":"market.status","ask":["is the NASDAQ open?","the US market is open"],"tool":"market_hours"},{"route_id":"weather.city","ask":["what's the weather in Sydney?"],"tool":"current_weather"},{"route_id":"air_quality.city","ask":["what's the air quality in Delhi?"],"tool":"air_quality"},{"route_id":"treasury.yield","ask":["what is the 10 year treasury yield?"],"tool":"treasury_yields"}],"not_covered":["opinions and predictions","historical states","asset prices in question mode","any subject without a named live source in the tool catalog"],"refusal_contract":"questions outside these families return a SIGNED outside_evidence_coverage refusal; this endpoint never guesses","checks_mode":"structured checks reach ALL live tools directly and skip question parsing entirely; see GET /v1/answer usage + GET /tools.json"},"note":"No deterministic route from this text to a live evidence source exists in route/1. Dynamic Feed answers only what it can evidence; it never guesses. Structured checks reach all live tools directly (see GET /v1/answer).","principle":"signed proves integrity, not truth; verified is reserved for 2+ independent sources agreeing on this reading while fresh","advisory":true,"disclaimer":"ADVISORY EVIDENCE ONLY: a signed, timestamped record of what the named public sources reported at issue time. Not a certification and not a guarantee the underlying claim is true in the world. Tamper-evident, not tamper-proof.","served_by":{"provider":"Dynamic Feed","url":"https://dynamicfeed.ai","verify":"https://dynamicfeed.ai/.well-known/keys","facts":"https://dynamicfeed.ai/v1/facts","mcp":"https://dynamicfeed.ai/mcp","docs":"https://dynamicfeed.ai/llms.txt"},"signature":{"alg":"Ed25519","key_id":"df-ed25519-4cb32e72f333","canonicalization":"json-sorted-compact","sig":"Szc-rsKecelnsQRBNw-fyWMo9LDpZPf97vvlITSh4Ha6_PLAXVw6Km5J0G4Kr18zyXiaDPXOWDhjesXMUYYuAA=="},"anchor":{"status":"self_anchor","digest_sha256":"a0776acaa675c6717d226dd8f1da3633469675b0dfab349885cd1212512882c0","how":"save the receipt without its `anchor` field as canonical JSON (keys sorted, compact separators, UTF-8), then: openssl ts -query -data receipt.json -sha256 -cert | curl -s -H 'Content-Type: application/timestamp-query' --data-binary @- https://freetsa.org/tsr > receipt.tsr","note":"any holder can obtain RFC 3161 tokens from any public TSA at any time; Dynamic Feed is not a dependency for proof-of-when. Inline anchoring is available with an API key (anchor: true)."}} diff --git a/clients/python/tests/fixtures/answer_pubkey.txt b/clients/python/tests/fixtures/answer_pubkey.txt new file mode 100644 index 0000000..6af663e --- /dev/null +++ b/clients/python/tests/fixtures/answer_pubkey.txt @@ -0,0 +1 @@ +O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk= diff --git a/clients/python/tests/fixtures/awareness_noanchor.json b/clients/python/tests/fixtures/awareness_noanchor.json new file mode 100644 index 0000000..2b3a98a --- /dev/null +++ b/clients/python/tests/fixtures/awareness_noanchor.json @@ -0,0 +1 @@ +{"snapshot_id":"0077b6e019464cfe9c546bf639073e55","issued_at":"2026-07-09T10:22:16Z","issuer":"dynamicfeed.ai","schema":"awareness/v1","robot":{"id":null,"class":"aerial"},"location":{"lat":51.5,"lon":-0.12,"alt_m":null},"verdict":{"status":"go","reasons":["all checked conditions within limits"],"advisory":"Clear to proceed, all checked conditions within limits."},"facts":[{"id":"wx.wind_kmh","domain":"weather","value":5.8,"unit":"km/h","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.precip_mm","domain":"weather","value":0.0,"unit":"mm","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.temp_c","domain":"weather","value":28.5,"unit":"°C","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.conditions","domain":"weather","value":"Mainly clear","unit":null,"observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"sw.kp_index","domain":"space","value":2.33,"unit":"Kp","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"sw.storm_g","domain":"space","value":0,"unit":"G-scale","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"sw.storm_s","domain":"space","value":0,"unit":"S-scale","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"aq.us_aqi","domain":"air_quality","value":35,"unit":"US AQI","observed_at":"2026-07-09T11:00+01:00","age_s":1336,"source":"Open-Meteo Air Quality","source_url":"https://open-meteo.com","confidence":0.95,"stale":false,"category":"Good"}],"freshness":{"min_age_s":436,"max_age_s":15736,"stalest_field":"sw.kp_index"},"degraded":false,"degraded_sources":[],"note":"awareness/v1 (Rung A+B): signed verdict + grounded facts over live feeds; never blocks (hard deadline → degraded; fail-safe floors to caution, incl. on stale safety data). Verify the Ed25519 signature against GET /.well-known/keys (see scripts/verify_awareness.py). Public chain-anchoring of the signature is Rung C.","advisory":true,"disclaimer":"ADVISORY EVIDENCE ONLY: a signed, timestamped snapshot of what public data showed at issue time. This is NOT a safety certification, safety system or safety function, and NOT a guarantee that acting is safe. Your control stack owns every action and must apply its own safety logic. The signature proves this snapshot's existence and integrity, not the safety of any decision made from it.","signature":{"alg":"Ed25519","key_id":"df-ed25519-4cb32e72f333","canonicalization":"json-sorted-compact","sig":"b2XlAHX2TASu-2hErXorzs3_xZMlbKOpK8Xw8pA06db4XPwIR-AOQpNdFhm0zwR_v-VZCz0zG8RcqQQsiQNKCg=="}} diff --git a/clients/python/tests/fixtures/awareness_pubkey.txt b/clients/python/tests/fixtures/awareness_pubkey.txt new file mode 100644 index 0000000..6af663e --- /dev/null +++ b/clients/python/tests/fixtures/awareness_pubkey.txt @@ -0,0 +1 @@ +O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk= diff --git a/clients/python/tests/test_anchor.py b/clients/python/tests/test_anchor.py new file mode 100644 index 0000000..bee64d8 --- /dev/null +++ b/clients/python/tests/test_anchor.py @@ -0,0 +1,190 @@ +"""Offline regression tests for the anchor-convention fix (dynamicfeed-verify). + +A /v1/answer receipt carries a top-level ``anchor`` block attached AFTER signing (an RFC 3161 / DLT +anchor is a timestamp OF the signed hash, so it can only land post-signing). A verifier that strips +only ``signature`` recomputes the wrong bytes and wrongly reports INVALID. These fixtures were +captured live from dynamicfeed.ai (2026-07-09); the key df-ed25519-… is persistent. No network. + +Run: python -m pytest clients/python/tests (or) python clients/python/tests/test_anchor.py +""" +import copy +import json +import pathlib +from unittest import mock + +import dynamicfeed_verify as verifier +from dynamicfeed_verify import _b64d, _strict_loads, validate_lifecycle_registry, verify + +FIX = pathlib.Path(__file__).parent / "fixtures" +REGISTRY = json.loads((pathlib.Path(__file__).parents[3] / "SIGNING_KEY_LIFECYCLE.json").read_text()) + + +def _jwks(kid_pubkey_file: str, kid: str) -> dict: + return {kid: (FIX / kid_pubkey_file).read_text().strip()} + + +def _load(name: str) -> dict: + return json.loads((FIX / name).read_text()) + + +def test_anchored_answer_crypto_valid_but_compromised_lifecycle_rejected(): + env = _load("answer_anchored.json") + assert "anchor" in env, "fixture must carry an anchor block" + res = verify(env, lifecycle_registry=REGISTRY) + assert res["crypto_valid"] is True and res["ok"] is False, res + assert res["lifecycle_status"] == "compromised" + assert res["anchor_authenticated"] is False + + +def test_noanchor_awareness_crypto_valid_but_compromised_lifecycle_rejected(): + env = _load("awareness_noanchor.json") + assert "anchor" not in env, "fixture must have no anchor block" + res = verify(env, lifecycle_registry=REGISTRY) + assert res["crypto_valid"] is True and res["ok"] is False, res + assert res["lifecycle_status"] == "compromised" + assert res["anchor_authenticated"] is None + + +def test_flat_key_map_is_crypto_only(): + env = _load("awareness_noanchor.json") + kid = env["signature"]["key_id"] + res = verify(env, jwks=_jwks("awareness_pubkey.txt", kid)) + assert res["crypto_valid"] is True and res["ok"] is False + assert res["lifecycle_status"] == "unknown" + + +def test_body_tamper_is_invalid(): + env = _load("answer_anchored.json") + kid = env["signature"]["key_id"] + bad = copy.deepcopy(env) + for k, v in bad.items(): + if k not in ("signature", "anchor") and isinstance(v, str) and v: + bad[k] = v + "X" + break + res = verify(bad, lifecycle_registry=REGISTRY) + assert not res["crypto_valid"] and not res["ok"], "a tampered body must verify INVALID" + + +def test_live_registry_revision_floor_is_fail_closed_and_not_self_supplied(): + for value in (None, False, 0, 1.5): + bad = copy.deepcopy(REGISTRY) + if value is None: + bad.pop("registry_revision", None) + else: + bad["registry_revision"] = value + try: + validate_lifecycle_registry(bad) + except ValueError as exc: + assert "registry_revision" in str(exc) + else: + raise AssertionError(f"invalid revision accepted: {value!r}") + + assert validate_lifecycle_registry(REGISTRY, minimum_registry_revision=1) is REGISTRY + try: + validate_lifecycle_registry(REGISTRY, minimum_registry_revision=2) + except ValueError as exc: + assert "below effective floor" in str(exc) + else: + raise AssertionError("caller-retained floor was ignored") + + +def test_historical_snapshot_is_explicit_non_actionable_and_revision_visible(): + env = _load("awareness_noanchor.json") + result = verify( + env, lifecycle_registry=REGISTRY, verification_mode="historical_snapshot", + minimum_registry_revision=2, registry_source_authenticated=True, + ) + assert result["ok"] is False + assert result["historical_snapshot"] is True + assert result["mode"] == "historical_snapshot" + assert result["registry_revision"] == 1 + assert result["effective_registry_floor"] == 0 + assert result["rollback_protected"] is False + assert result["lifecycle_status"] == "compromised" + + +def test_live_result_discloses_revision_and_no_durable_floor_claim(): + env = _load("awareness_noanchor.json") + result = verify(env, lifecycle_registry=REGISTRY, registry_source_authenticated=True) + assert result["registry_revision"] == 1 + assert result["effective_registry_floor"] == 1 + assert result["mode"] == "live" + assert result["rollback_protected"] is False + + +def test_strict_json_base64_and_timestamp_inputs_fail_closed(): + assert verify([])["ok"] is False + assert verify({"signature": "attacker"})["ok"] is False + for raw in ('{"schema":"a","schema":"b"}', '{"value":NaN}'): + try: + _strict_loads(raw) + except ValueError: + pass + else: + raise AssertionError(f"non-strict JSON accepted: {raw}") + for encoded in ("!!!!", "A", "AA=", "abc$", "===="): + try: + _b64d(encoded) + except ValueError: + pass + else: + raise AssertionError(f"malformed base64url accepted: {encoded}") + bad = copy.deepcopy(REGISTRY) + bad["updated_at"] = "2026-02-30T10:00:00Z" + try: + validate_lifecycle_registry(bad) + except ValueError: + pass + else: + raise AssertionError("invalid calendar timestamp accepted") + malformed = _load("awareness_noanchor.json") + malformed["signature"]["sig"] = "!!!!" + result = verify( + malformed, lifecycle_registry=REGISTRY, registry_source_authenticated=True, + ) + assert result["ok"] is False and result["crypto_valid"] is False + assert "encoding" in result["error"] + + +def test_custom_network_origin_does_not_self_authenticate(): + env = _load("awareness_noanchor.json") + key_id = env["signature"]["key_id"] + active_registry = copy.deepcopy(REGISTRY) + active_registry["active_key_id"] = key_id + active_registry["public_keys"] = {key_id: REGISTRY["public_keys"][key_id]} + metadata = active_registry["lifecycle"][key_id] + metadata.update({"status": "active", "retired_at": None, "compromised_after": None}) + active_registry["lifecycle"] = {key_id: metadata} + supplied = verifier.verify(env, lifecycle_registry=active_registry) + assert supplied["crypto_valid"] is True and supplied["ok"] is False + assert supplied["error"] == "lifecycle registry source was not explicitly authenticated" + supplied_authenticated = verifier.verify( + env, lifecycle_registry=active_registry, registry_source_authenticated=True, + ) + assert supplied_authenticated["ok"] is True + + with mock.patch.object(verifier, "fetch_lifecycle_registry", return_value=active_registry): + custom = verifier.verify( + env, base="https://mirror.example", + highest_authenticated_registry_revision=1, + ) + assert custom["trust_basis"] == "caller-configured-network-origin" + assert custom["registry_source_authenticated"] is False + assert custom["rollback_protected"] is False + assert custom["ok"] is False + explicit = verifier.verify( + env, base="https://mirror.example", + highest_authenticated_registry_revision=1, + registry_source_authenticated=True, + ) + assert explicit["registry_source_authenticated"] is True + assert explicit["rollback_protected"] is True + assert explicit["ok"] is True + + +if __name__ == "__main__": + test_anchored_answer_crypto_valid_but_compromised_lifecycle_rejected() + test_noanchor_awareness_crypto_valid_but_compromised_lifecycle_rejected() + test_flat_key_map_is_crypto_only() + test_body_tamper_is_invalid() + print("PASS — historical crypto valid, compromised lifecycle rejected, body tamper caught") diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock index ec34cef..0163ca8 100644 --- a/clients/rust/Cargo.lock +++ b/clients/rust/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "base64" version = "0.22.1" @@ -23,6 +29,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -44,6 +60,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -101,13 +126,25 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dynamicfeed-verify" -version = "1.0.1" +version = "1.0.2" dependencies = [ "base64", "ed25519-dalek", - "serde_json", + "sha2", + "ureq", ] [[package]] @@ -140,6 +177,31 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -162,10 +224,107 @@ dependencies = [ ] [[package]] -name = "itoa" -version = "1.0.18" +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] [[package]] name = "libc" @@ -174,10 +333,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "memchr" -version = "2.8.2" +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pkcs8" @@ -189,6 +376,15 @@ dependencies = [ "spki", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -200,9 +396,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -216,6 +412,20 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -225,6 +435,41 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "semver" version = "1.0.28" @@ -260,19 +505,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - [[package]] name = "sha2" version = "0.10.9" @@ -284,6 +516,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" version = "2.2.0" @@ -293,6 +531,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "spki" version = "0.7.3" @@ -303,6 +553,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -311,15 +567,36 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.20.1" @@ -332,6 +609,46 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -340,9 +657,150 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] [[package]] name = "zeroize" @@ -351,7 +809,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zmij" -version = "1.0.21" +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml index dc0b118..eef210f 100644 --- a/clients/rust/Cargo.toml +++ b/clients/rust/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "dynamicfeed-verify" -version = "1.0.1" +version = "1.0.2" edition = "2021" -description = "Independently verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses. No account, no runtime trust in the issuer." +description = "Verify Dynamic Feed DF-VERIFY/1 signatures and signing-key lifecycle policy." license = "MIT" readme = "README.md" homepage = "https://dynamicfeed.ai/standard" @@ -19,6 +19,12 @@ name = "dynamicfeed-verify" path = "src/main.rs" [dependencies] -serde_json = { version = "1", features = ["arbitrary_precision"] } ed25519-dalek = "2" base64 = "0.22" +sha2 = "0.10" +ureq = { version = "2", optional = true } + +[features] +default = [] +# Optional networked lifecycle-registry fetching. Core verification is no-network. +fetch = ["dep:ureq"] diff --git a/clients/rust/README.md b/clients/rust/README.md index 3bf82bb..a430c24 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -1,49 +1,169 @@ # dynamicfeed-verify (Rust) -Independently verify [Dynamic Feed](https://dynamicfeed.ai) **DF-VERIFY/1** Ed25519-signed responses in Rust. No account, no runtime trust in Dynamic Feed beyond fetching the public key. You can verify, even against us. +Ed25519 verifier for [Dynamic Feed](https://dynamicfeed.ai) signed envelopes (**DF-VERIFY/1**). + +Signed Dynamic Feed response profiles carry DF-VERIFY/1 envelopes. This crate lets Rust verify +their signature bytes and lifecycle policy offline, byte-identical with the other implementations +in this repository: + +- Python — [`../python`](../python) +- JavaScript — [`../js`](../js) +- Rust — this crate + +Spec: https://dynamicfeed.ai/standard + +## The envelope + +```json +{ + "...payload fields...": "...", + "signature": { + "alg": "Ed25519", + "key_id": "df-ed25519-6ca0de29113b", + "canonicalization": "json-sorted-compact", + "sig": "" + } +} +``` + +The signature covers the **canonical bytes**: the JSON object *without* its `signature` field, +serialized exactly like Python's `json.dumps(obj, sort_keys=True, separators=(",", ":"))` — +sorted keys, compact separators, `ensure_ascii` escaping. + +## The two canonicalization traps -Reference implementation of the [DF-VERIFY/1 standard](https://dynamicfeed.ai/standard). Byte-for-byte identical canonicalization to the Python, JavaScript and C# verifiers: it passes the shared conformance vectors in [`tests/vectors`](../../tests/vectors). +A naive verifier (parse with any JSON library, re-serialize, verify) breaks on real envelopes. +Two traps, both solved here the same way `web/verify.js` solves them: -## Library +1. **Python `ensure_ascii` escaping.** Responses travel over the wire as raw UTF-8 (`"unit":"°C"`), + but the server signed the ASCII-escaped form (`"unit":"\u00b0C"`). Every non-ASCII character + must be re-escaped to `\uXXXX` (lowercase hex; astral characters become a UTF-16 surrogate + pair, e.g. 🤖 → `\ud83e\udd16`) before checking the signature. + +2. **Lossless numbers.** Numbers must round-trip **exactly** as they appear in the source text. + Parsing `-50.0` into a float and re-printing it can change the spelling (`-50`, or `1e-07` + becoming `1e-7` under serde_json), which flips bytes and invalidates the signature. This crate + uses its own small JSON parser that keeps every number **verbatim as a text slice** — no float + formatting is ever involved. + +## Library — lifecycle policy mode ```rust -use dynamicfeed_verify::{parse, verify}; +// Offline — no network. Pin the reviewed lifecycle registry outside the receipt channel. +let envelope_text = std::fs::read_to_string("envelope.json")?; +let registry_text = std::fs::read_to_string("signing-key-lifecycle.json")?; +let registry = dynamicfeed_verify::LifecycleRegistry::parse_with_options( + ®istry_text, + dynamicfeed_verify::RegistryValidationOptions { + registry_source_authenticated: true, + ..Default::default() + }, +)?; +let result = dynamicfeed_verify::verify_with_registry(&envelope_text, ®istry)?; +assert!(result.accepted); // signature mathematics AND signer lifecycle policy +println!("POLICY_ACCEPTED — key_id={}", result.key_id); + +// Canonical bytes (what the signature actually covers): +let bytes = dynamicfeed_verify::canonical_bytes(&envelope_text)?; +``` -let env = parse(&response_text)?; // arbitrary_precision: numbers kept verbatim -let ok = verify(&env, &keys_map)?; // keys: key_id -> base64url public key +`verify_envelope` and `verify_with_keys` are deliberately lower-level, **crypto-only** APIs. They +can establish Ed25519 signature mathematics and key-id binding, but a flat key map contains no +retired/compromised status. Never interpret their `valid` field as signer acceptance: + +```rust +let keys = dynamicfeed_verify::Keys::parse(&keys_json_text)?; +let crypto_only = dynamicfeed_verify::verify_with_keys(&envelope_text, &keys)?; +assert!(crypto_only.valid); // CRYPTO_VALID; LIFECYCLE_UNCHECKED ``` -## CLI +Their `anchor_authenticated` field also preserves the two frozen receipt conventions: `Some(true)` +means an attached anchor was inside the verified signature scope, `Some(false)` means legacy +post-signature attachment, and `None` means no anchor (or no passing signature form). Signature +validity must never be used to infer authenticity of a legacy attached timestamp artifact. -```bash -cargo run -- response.json keys.json # verify a saved signed response -cargo run -- --canonical response.json # print the exact canonical bytes +With the optional **`fetch`** feature (the core stays no-network): + +```rust +let registry = dynamicfeed_verify::fetch_lifecycle_registry( + dynamicfeed_verify::DEFAULT_LIFECYCLE_URL, +)?; +let result = dynamicfeed_verify::verify_with_registry(&envelope_text, ®istry)?; ``` -Fetch the keys however you like; the verifier has no network dependency: +The default URL is a current-domain operational disclosure, not an independent trust root. Pin a +reviewed registry out of band when the verifier is a security or evidence policy gate. +Caller-supplied files and custom registry URLs are not accepted as authenticated merely because +they were supplied; set `registry_source_authenticated` only after authenticating that source. +Network fetches reject redirects. -```bash -curl -s https://dynamicfeed.ai/.well-known/keys > keys.json +Live verification enforces `COMPILED_MIN_REGISTRY_REVISION` plus any caller-supplied minimum and +the caller's highest previously authenticated revision. The registry's own revision cannot protect +itself from rollback: persist an authenticated highest revision outside the document and pass it +back through `RegistryValidationOptions`. `HistoricalSnapshot` mode must be selected explicitly; +it can report historical signature mathematics and policy-as-of status but never returns +`accepted=true` and must not authorize a live action. + +```rust +let options = dynamicfeed_verify::RegistryValidationOptions { + highest_authenticated_registry_revision: Some(1), + registry_source_authenticated: true, + ..Default::default() +}; +let registry = dynamicfeed_verify::LifecycleRegistry::parse_with_options( + ®istry_text, + options, +)?; +``` + +```toml +[dependencies] +dynamicfeed-verify = { version = "1.0.2", features = ["fetch"] } # target release; not published by this PR ``` -## How it works +## CLI -1. Drop the top-level `signature` field; keep the rest as the payload. -2. Canonicalize (`json-sorted-compact`): keys sorted recursively, compact separators, non-ASCII escaped `\uXXXX` (astral characters as UTF-16 surrogate pairs), numbers verbatim, UTF-8. -3. Verify the detached Ed25519 signature over the canonical bytes against the key named in `signature.key_id`. Change one byte and it fails. +```sh +# offline policy mode, pinned registry +dynamicfeed-verify envelope.json --registry SIGNING_KEY_LIFECYCLE.json \ + --registry-source-authenticated -The cardinal rule it enforces: **signed is not verified**. A signature proves integrity, not truth. +# enforce a caller-retained authenticated revision floor +dynamicfeed-verify envelope.json --registry SIGNING_KEY_LIFECYCLE.json \ + --highest-authenticated-registry-revision 1 --registry-source-authenticated -## Conformance +# non-actionable historical inspection (always exits non-zero for policy acceptance) +dynamicfeed-verify envelope.json --registry SIGNING_KEY_LIFECYCLE.json --historical-snapshot -```bash -cargo test +# from stdin, same pinned policy +curl -s 'https://dynamicfeed.ai/v1/attest?metric=temperature_c&op=gte&threshold=-50&lat=-33.87&lon=151.21' \ + | dynamicfeed-verify --registry SIGNING_KEY_LIFECYCLE.json + +# fetch the current-domain lifecycle registry (requires --features fetch) +dynamicfeed-verify envelope.json + +# explicit signature-mathematics diagnostic; no lifecycle acceptance +dynamicfeed-verify envelope.json --key acMWky59eQfoVqCUgmUM3tcl35YioRngL4wVDIQsstg= --crypto-only ``` -runs the language-agnostic vectors in [`../../tests/vectors`](../../tests/vectors), the same ones the Python and JS harnesses use: every canonicalization vector must match byte-for-byte, the authentic envelope must verify, and the tampered twin must be rejected. +Policy mode prints `POLICY_ACCEPTED`, `CRYPTO_VALID LIFECYCLE_REJECTED`, or `CRYPTO_INVALID` and +exits `0` only for policy acceptance. Explicit `--crypto-only` prints +`CRYPTO_VALID LIFECYCLE_UNCHECKED` or `CRYPTO_INVALID`. + +## Conformance + +`tests/conformance.rs` runs against frozen historical awareness and anchored-answer fixtures plus +generated active-key material: -Full specification: **https://dynamicfeed.ai/standard** +- preserved historical bytes remain cryptographically valid while the compromised signer is + lifecycle-rejected by the reviewed registry; +- a generated active-key fixture passes signature and lifecycle policy; +- flipping a single payload byte makes it **INVALID**; +- revision rollback below a caller-retained floor fails closed; +- historical snapshot mode never returns live acceptance; +- duplicate JSON keys and rewritten signature metadata fail closed; +- a legacy post-signature anchor reports `anchor_authenticated = Some(false)`. ## License -MIT. +MIT diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs index 5b3ea4a..8f8b37f 100644 --- a/clients/rust/src/lib.rs +++ b/clients/rust/src/lib.rs @@ -1,152 +1,1322 @@ -//! Rust reference verifier for DF-VERIFY/1 Ed25519-signed responses. +//! # dynamicfeed-verify — Ed25519 and lifecycle-policy verifier for DF-VERIFY/1 //! -//! Spec: . Reproduces the `json-sorted-compact` -//! canonicalization byte-for-byte (keys sorted recursively, compact separators, -//! non-ASCII escaped `\uXXXX` including astral surrogate pairs, top-level -//! `signature` stripped, numbers verbatim) and checks the detached Ed25519 -//! signature against the published key. The cardinal rule it enforces: -//! **signed != verified** — a signature proves integrity, not truth. - -use base64::Engine; +//! Signed [Dynamic Feed](https://dynamicfeed.ai) response profiles use a JSON envelope carrying a +//! top-level `"signature"` block: +//! +//! ```json +//! { +//! "...payload fields...": "...", +//! "signature": { +//! "alg": "Ed25519", +//! "key_id": "df-ed25519-…", +//! "canonicalization": "json-sorted-compact", +//! "sig": "" +//! } +//! } +//! ``` +//! +//! The signature covers the **canonical bytes** of the envelope: the JSON object *without* its +//! `"signature"` field, serialized exactly like Python's +//! `json.dumps(obj, sort_keys=True, separators=(",", ":"))` — sorted keys, compact separators, +//! and `ensure_ascii` escaping (every non-ASCII character becomes `\uXXXX`, astral characters +//! become a surrogate pair `\uD8xx\uDCxx`). +//! +//! ## The two canonicalization traps +//! +//! This crate handles the same two traps as the Python and JavaScript clients in this repository: +//! +//! 1. **`ensure_ascii` escaping.** The server canonicalizes with Python's default +//! `ensure_ascii=True`, but responses travel over the wire as raw UTF-8 (the wire says `"°C"`). +//! A verifier must re-escape every non-ASCII character back to its ASCII `\uXXXX` form +//! (`°` becomes `\u00b0`) before checking the signature. +//! 2. **Lossless numbers.** Numbers must round-trip *exactly* as they appear in the source text. +//! Parsing `-50.0` into an `f64` and re-printing it can change the spelling (`1e-07` vs +//! `1e-7`, `-50.0` vs `-50`), which flips bytes and breaks the signature. This crate uses its +//! own small JSON parser that keeps every number **verbatim** as a text slice — the same +//! approach as `web/verify.js` — so no float formatting is ever involved. +//! +//! ## Quick start (no network) +//! +//! ```no_run +//! let envelope_text = std::fs::read_to_string("envelope.json").unwrap(); +//! let pubkey_b64 = "acMWky59eQfoVqCUgmUM3tcl35YioRngL4wVDIQsstg="; // active key from /.well-known/keys +//! let v = dynamicfeed_verify::verify_envelope(&envelope_text, pubkey_b64).unwrap(); +//! assert!(v.valid); // Ed25519 mathematics only; lifecycle policy is not evaluated here +//! println!("CRYPTO_VALID — key_id={}", v.key_id); +//! ``` +//! +//! With the optional `fetch` feature, [`fetch_keys`] retrieves cryptographic key bytes from +//! `https://dynamicfeed.ai/.well-known/keys` (a flat `{ "": "", ... }` +//! map. That endpoint contains key bytes only and cannot establish lifecycle acceptance. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use base64::engine::general_purpose::STANDARD_NO_PAD; +use base64::Engine as _; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; -use serde_json::Value; -use std::collections::BTreeMap; - -/// Canonicalize a payload to the exact bytes that were signed. -/// Equivalent to Python `json.dumps(payload_without_top_level_signature, -/// sort_keys=True, separators=(",", ":"))` with default `ensure_ascii=True`. -pub fn canonical(env: &Value) -> Vec { - let mut out = Vec::new(); - match env { - // strip the top-level `signature` field only - Value::Object(map) => { - out.push(b'{'); - let sorted: BTreeMap<&String, &Value> = map - .iter() - .filter(|(k, _)| k.as_str() != "signature" && k.as_str() != "anchor") - .collect(); - for (i, (k, v)) in sorted.iter().enumerate() { - if i > 0 { - out.push(b','); - } - write_str(k, &mut out); - out.push(b':'); - write_value(v, &mut out); +use sha2::{Digest, Sha256}; + +/// The published JWKS endpoint for dynamicfeed.ai. +pub const DEFAULT_KEYS_URL: &str = "https://dynamicfeed.ai/.well-known/keys"; +/// The lifecycle-policy endpoint used by policy-aware clients. +pub const DEFAULT_LIFECYCLE_URL: &str = + "https://dynamicfeed.ai/.well-known/signing-key-registry.json"; +/// Minimum lifecycle-registry revision this release will accept for live verification. +pub const COMPILED_MIN_REGISTRY_REVISION: u64 = 1; + +/// Whether a lifecycle document is being used for a live policy decision or inspected as a +/// deliberately non-actionable historical snapshot. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum VerificationMode { + #[default] + Live, + HistoricalSnapshot, +} + +/// Caller-retained anti-rollback inputs for lifecycle-registry validation. +/// +/// A registry's self-declared revision is not an independent anti-rollback mechanism. Callers +/// that authenticate a revision must persist it outside the registry and pass it back through +/// `highest_authenticated_registry_revision` on every later live verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RegistryValidationOptions { + pub mode: VerificationMode, + pub minimum_registry_revision: Option, + pub highest_authenticated_registry_revision: Option, + /// Set only after authenticating caller-supplied/custom-origin registry material. Live signer + /// acceptance remains false when this is false. + pub registry_source_authenticated: bool, +} + +impl Default for RegistryValidationOptions { + fn default() -> Self { + Self { + mode: VerificationMode::Live, + minimum_registry_revision: None, + highest_authenticated_registry_revision: None, + registry_source_authenticated: false, + } + } +} + +// ------------------------------------------------------------------------------------------------ +// Errors +// ------------------------------------------------------------------------------------------------ + +/// Everything that can go wrong while parsing or verifying an envelope. +/// +/// Note: a *well-formed* envelope whose signature simply doesn't match is **not** an error — +/// [`verify_envelope`] returns `Ok(Verification { valid: false, .. })` for that case. `Err` means +/// the input was structurally unusable (bad JSON, missing signature block, undecodable key…). +#[derive(Debug)] +pub enum Error { + /// The input is not valid JSON (position/context in the message). + Json(String), + /// The top-level JSON value is not an object. + NotAnObject, + /// The envelope has no top-level `"signature"` object. + NoSignature, + /// The `"signature"` block is missing a required field or has the wrong type. + BadSignatureBlock(String), + /// A base64 / base64url field failed to decode. + Base64(String), + /// The public key bytes are not a valid Ed25519 key (or wrong length). + Key(String), + /// The envelope's `key_id` was not found in the supplied [`Keys`]. + UnknownKeyId(String), + /// The signing-key lifecycle registry is missing, malformed, or inconsistent. + Lifecycle(String), + /// Network or HTTP failure while fetching keys (only with the `fetch` feature). + #[cfg(feature = "fetch")] + Fetch(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Json(m) => write!(f, "invalid JSON — {m}"), + Error::NotAnObject => write!(f, "expected a top-level JSON object"), + Error::NoSignature => write!(f, "no \"signature\" block in this envelope"), + Error::BadSignatureBlock(m) => write!(f, "bad signature block — {m}"), + Error::Base64(m) => write!(f, "base64 decode failed — {m}"), + Error::Key(m) => write!(f, "bad public key — {m}"), + Error::UnknownKeyId(id) => { + write!(f, "key_id {id} not in the supplied keys (rotated?)") } - out.push(b'}'); + Error::Lifecycle(m) => write!(f, "invalid signing-key lifecycle registry — {m}"), + #[cfg(feature = "fetch")] + Error::Fetch(m) => write!(f, "could not fetch keys — {m}"), } - other => write_value(other, &mut out), } - out } -fn write_value(v: &Value, out: &mut Vec) { - match v { - Value::Null => out.extend_from_slice(b"null"), - Value::Bool(true) => out.extend_from_slice(b"true"), - Value::Bool(false) => out.extend_from_slice(b"false"), - // arbitrary_precision: the Number serializes back to its exact source text - Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()), - Value::String(s) => write_str(s, out), - Value::Array(a) => { - out.push(b'['); - for (i, e) in a.iter().enumerate() { - if i > 0 { - out.push(b','); +impl std::error::Error for Error {} + +// ------------------------------------------------------------------------------------------------ +// Lossless JSON parse (numbers kept VERBATIM, mirroring web/verify.js `lparse`) +// ------------------------------------------------------------------------------------------------ + +#[derive(Debug, Clone, PartialEq)] +enum Node { + /// Key/value pairs in source order (canonicalization sorts them later). + Object(Vec<(String, Node)>), + Array(Vec), + Str(String), + /// Number kept exactly as it appeared in the source text — never re-formatted. + Num(String), + Bool(bool), + Null, +} + +struct Parser { + chars: Vec, + i: usize, +} + +impl Parser { + fn new(s: &str) -> Self { + Parser { + chars: s.chars().collect(), + i: 0, + } + } + + fn peek(&self) -> Option { + self.chars.get(self.i).copied() + } + + fn ws(&mut self) { + while matches!(self.peek(), Some(' ' | '\t' | '\n' | '\r')) { + self.i += 1; + } + } + + fn expect(&mut self, c: char) -> Result<(), Error> { + if self.peek() == Some(c) { + self.i += 1; + Ok(()) + } else { + Err(Error::Json(format!( + "expected '{c}' at char {} (found {:?})", + self.i, + self.peek() + ))) + } + } + + fn literal(&mut self, lit: &str) -> Result<(), Error> { + for c in lit.chars() { + if self.peek() != Some(c) { + return Err(Error::Json(format!("bad literal at char {}", self.i))); + } + self.i += 1; + } + Ok(()) + } + + fn value(&mut self) -> Result { + self.ws(); + match self.peek() { + Some('{') => self.object(), + Some('[') => self.array(), + Some('"') => Ok(Node::Str(self.string()?)), + Some('t') => self.literal("true").map(|_| Node::Bool(true)), + Some('f') => self.literal("false").map(|_| Node::Bool(false)), + Some('n') => self.literal("null").map(|_| Node::Null), + Some(c) if c == '-' || c.is_ascii_digit() => self.number(), + Some(c) => Err(Error::Json(format!("unexpected '{c}' at char {}", self.i))), + None => Err(Error::Json("unexpected end of input".into())), + } + } + + fn object(&mut self) -> Result { + self.expect('{')?; + let mut pairs = Vec::new(); + let mut seen = BTreeSet::new(); + self.ws(); + if self.peek() == Some('}') { + self.i += 1; + return Ok(Node::Object(pairs)); + } + loop { + self.ws(); + let key = self.string()?; + if !seen.insert(key.clone()) { + return Err(Error::Json(format!( + "duplicate object key '{key}' at char {}", + self.i + ))); + } + self.ws(); + self.expect(':')?; + let val = self.value()?; + pairs.push((key, val)); + self.ws(); + match self.peek() { + Some(',') => { + self.i += 1; + } + Some('}') => { + self.i += 1; + break; + } + _ => { + return Err(Error::Json(format!( + "expected ',' or '}}' at char {}", + self.i + ))) + } + } + } + Ok(Node::Object(pairs)) + } + + fn array(&mut self) -> Result { + self.expect('[')?; + let mut items = Vec::new(); + self.ws(); + if self.peek() == Some(']') { + self.i += 1; + return Ok(Node::Array(items)); + } + loop { + items.push(self.value()?); + self.ws(); + match self.peek() { + Some(',') => { + self.i += 1; + } + Some(']') => { + self.i += 1; + break; + } + _ => { + return Err(Error::Json(format!( + "expected ',' or ']' at char {}", + self.i + ))) + } + } + } + Ok(Node::Array(items)) + } + + fn hex4(&mut self) -> Result { + let mut v: u16 = 0; + for _ in 0..4 { + let c = self + .peek() + .ok_or_else(|| Error::Json("truncated \\u escape".into()))?; + let d = c + .to_digit(16) + .ok_or_else(|| Error::Json(format!("bad \\u escape at char {}", self.i)))?; + v = (v << 4) | d as u16; + self.i += 1; + } + Ok(v) + } + + fn string(&mut self) -> Result { + self.expect('"')?; + let mut out = String::new(); + loop { + let c = self + .peek() + .ok_or_else(|| Error::Json("unterminated string".into()))?; + self.i += 1; + match c { + '"' => return Ok(out), + '\\' => { + let e = self + .peek() + .ok_or_else(|| Error::Json("truncated escape".into()))?; + self.i += 1; + match e { + '"' => out.push('"'), + '\\' => out.push('\\'), + '/' => out.push('/'), + 'b' => out.push('\u{0008}'), + 'f' => out.push('\u{000C}'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'u' => { + let hi = self.hex4()?; + if (0xD800..0xDC00).contains(&hi) { + // high surrogate — must be followed by \uDCxx low surrogate + if self.peek() == Some('\\') { + self.i += 1; + if self.peek() == Some('u') { + self.i += 1; + let lo = self.hex4()?; + if (0xDC00..0xE000).contains(&lo) { + let cp = 0x10000 + + (((hi as u32) - 0xD800) << 10) + + ((lo as u32) - 0xDC00); + out.push(char::from_u32(cp).ok_or_else(|| { + Error::Json("bad surrogate pair".into()) + })?); + continue; + } + } + } + return Err(Error::Json(format!( + "lone high surrogate \\u{hi:04x} at char {}", + self.i + ))); + } else if (0xDC00..0xE000).contains(&hi) { + return Err(Error::Json(format!( + "lone low surrogate \\u{hi:04x} at char {}", + self.i + ))); + } + out.push( + char::from_u32(hi as u32) + .ok_or_else(|| Error::Json("bad \\u escape".into()))?, + ); + } + other => { + return Err(Error::Json(format!( + "bad escape '\\{other}' at char {}", + self.i + ))) + } + } + } + other if (other as u32) < 0x20 => { + return Err(Error::Json(format!( + "unescaped control character at char {}", + self.i - 1 + ))) } - write_value(e, out); + other => out.push(other), } - out.push(b']'); } - Value::Object(map) => { - out.push(b'{'); - let sorted: BTreeMap<&String, &Value> = map.iter().collect(); - for (i, (k, val)) in sorted.iter().enumerate() { - if i > 0 { - out.push(b','); + } + + fn number(&mut self) -> Result { + let start = self.i; + if self.peek() == Some('-') { + self.i += 1; + } + match self.peek() { + Some('0') => { + self.i += 1; + if self.peek().is_some_and(|c| c.is_ascii_digit()) { + return Err(Error::Json(format!( + "leading zero in number at char {start}" + ))); + } + } + Some('1'..='9') => { + self.i += 1; + while self.peek().is_some_and(|c| c.is_ascii_digit()) { + self.i += 1; } - write_str(k, out); - out.push(b':'); - write_value(val, out); } - out.push(b'}'); + _ => return Err(Error::Json(format!("expected a number at char {start}"))), } + if self.peek() == Some('.') { + self.i += 1; + if !self.peek().is_some_and(|c| c.is_ascii_digit()) { + return Err(Error::Json(format!( + "missing fraction digits at char {}", + self.i + ))); + } + while self.peek().is_some_and(|c| c.is_ascii_digit()) { + self.i += 1; + } + } + if matches!(self.peek(), Some('e' | 'E')) { + self.i += 1; + if matches!(self.peek(), Some('+' | '-')) { + self.i += 1; + } + if !self.peek().is_some_and(|c| c.is_ascii_digit()) { + return Err(Error::Json(format!( + "missing exponent digits at char {}", + self.i + ))); + } + while self.peek().is_some_and(|c| c.is_ascii_digit()) { + self.i += 1; + } + } + Ok(Node::Num(self.chars[start..self.i].iter().collect())) } } -/// String escaping matching Python's json encoder with `ensure_ascii=True`. -fn write_str(s: &str, out: &mut Vec) { - out.push(b'"'); +/// Parse the envelope text and return the top-level object's pairs in source order. +fn parse_object(text: &str) -> Result, Error> { + let mut p = Parser::new(text); + let root = p.value()?; + p.ws(); + if p.i != p.chars.len() { + return Err(Error::Json(format!("trailing data at char {}", p.i))); + } + match root { + Node::Object(pairs) => Ok(pairs), + _ => Err(Error::NotAnObject), + } +} + +/// Validate a JSON object with the verifier's strict parser without canonicalizing it. +/// Duplicate object names, malformed numbers, raw controls, and trailing bytes fail closed. +pub fn validate_json_strict(json_text: &str) -> Result<(), Error> { + parse_object(json_text).map(|_| ()) +} + +// ------------------------------------------------------------------------------------------------ +// Canonicalization (json-sorted-compact, Python json.dumps ensure_ascii semantics) +// ------------------------------------------------------------------------------------------------ + +/// Escape a string exactly like Python `json.dumps(..., ensure_ascii=True)`: +/// `"` `\` and the named control escapes, `\uXXXX` for other control chars and ALL non-ASCII, +/// surrogate-pair `\uD8xx\uDCxx` escapes for astral characters. +fn py_string(s: &str, out: &mut String) { + out.push('"'); for ch in s.chars() { + let c = ch as u32; match ch { - '"' => out.extend_from_slice(b"\\\""), - '\\' => out.extend_from_slice(b"\\\\"), - '\n' => out.extend_from_slice(b"\\n"), - '\r' => out.extend_from_slice(b"\\r"), - '\t' => out.extend_from_slice(b"\\t"), - '\u{08}' => out.extend_from_slice(b"\\b"), - '\u{0c}' => out.extend_from_slice(b"\\f"), - c if (c as u32) >= 0x20 && (c as u32) <= 0x7e => out.push(c as u8), - c => { - let cp = c as u32; - if cp <= 0xFFFF { - out.extend_from_slice(format!("\\u{:04x}", cp).as_bytes()); - } else { - // emit a UTF-16 surrogate pair, like Python's ensure_ascii - let v = cp - 0x10000; - let hi = 0xD800 + (v >> 10); - let lo = 0xDC00 + (v & 0x3FF); - out.extend_from_slice(format!("\\u{:04x}\\u{:04x}", hi, lo).as_bytes()); - } - } - } - } - out.push(b'"'); -} - -fn b64url(s: &str) -> Result, String> { - // tolerate missing padding, like Python's urlsafe_b64decode after re-padding - let t = s.trim_end_matches('='); - base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(t) - .map_err(|e| format!("base64: {e}")) -} - -/// Verify a signed envelope against a `key_id -> base64url public key` map. -/// Returns Ok(true) only if the detached Ed25519 signature over the canonical -/// bytes checks out. Never panics on malformed input; returns Ok(false)/Err. -pub fn verify(env: &Value, keys: &serde_json::Map) -> Result { - let sig = env - .get("signature") - .and_then(|s| s.as_object()) - .ok_or("no signature object")?; - let key_id = sig - .get("key_id") - .and_then(|v| v.as_str()) - .ok_or("no key_id")?; - let sig_b64 = sig.get("sig").and_then(|v| v.as_str()).ok_or("no sig")?; - let pk_b64 = keys - .get(key_id) - .and_then(|v| v.as_str()) - .ok_or("unknown key_id")?; - - let pk_bytes = b64url(pk_b64)?; - let sig_bytes = b64url(sig_b64)?; - let pk: [u8; 32] = pk_bytes - .as_slice() - .try_into() - .map_err(|_| "public key not 32 bytes")?; - let sg: [u8; 64] = sig_bytes + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{0008}' => out.push_str("\\b"), + '\u{000C}' => out.push_str("\\f"), + _ if c < 0x20 => { + out.push_str(&format!("\\u{c:04x}")); + } + _ if c <= 0x7E => out.push(ch), + _ if c > 0xFFFF => { + let x = c - 0x10000; + out.push_str(&format!( + "\\u{:04x}\\u{:04x}", + 0xD800 + (x >> 10), + 0xDC00 + (x & 0x3FF) + )); + } + _ => out.push_str(&format!("\\u{c:04x}")), + } + } + out.push('"'); +} + +fn canon(node: &Node, out: &mut String) { + match node { + Node::Object(pairs) => { + // Sorted keys. The parser has already rejected duplicate object names. + // Rust &str ordering is byte-wise UTF-8 == Unicode code-point order == Python's sort. + let mut map: BTreeMap<&str, &Node> = BTreeMap::new(); + for (k, v) in pairs { + map.insert(k.as_str(), v); + } + out.push('{'); + for (n, (k, v)) in map.iter().enumerate() { + if n > 0 { + out.push(','); + } + py_string(k, out); + out.push(':'); + canon(v, out); + } + out.push('}'); + } + Node::Array(items) => { + out.push('['); + for (n, v) in items.iter().enumerate() { + if n > 0 { + out.push(','); + } + canon(v, out); + } + out.push(']'); + } + Node::Str(s) => py_string(s, out), + Node::Num(t) => out.push_str(t), // VERBATIM — the whole point + Node::Bool(true) => out.push_str("true"), + Node::Bool(false) => out.push_str("false"), + Node::Null => out.push_str("null"), + } +} + +fn canonical_string_dropping(pairs: &[(String, Node)], drop: &[&str]) -> String { + let stripped: Vec<(String, Node)> = pairs + .iter() + .filter(|(k, _)| !drop.contains(&k.as_str())) + .cloned() + .collect(); + let mut out = String::new(); + canon(&Node::Object(stripped), &mut out); + out +} + +fn canonical_string(pairs: &[(String, Node)]) -> String { + canonical_string_dropping(pairs, &["signature"]) +} + +/// Compute the canonical bytes of a signed envelope: the JSON object **without** its +/// `"signature"` field, serialized as `json-sorted-compact` with Python `ensure_ascii` +/// escaping and verbatim (never re-formatted) numbers. +/// +/// These are exactly the bytes the Ed25519 signature covers, byte-identical with +/// `json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")`. +pub fn canonical_bytes(envelope_json_text: &str) -> Result, Error> { + let pairs = parse_object(envelope_json_text)?; + Ok(canonical_string(&pairs).into_bytes()) +} + +// ------------------------------------------------------------------------------------------------ +// Base64url (strict padded or unpadded form — matches the Python and JavaScript clients) +// ------------------------------------------------------------------------------------------------ + +fn b64_decode(s: &str) -> Result, Error> { + if s.is_empty() || s.trim() != s { + return Err(Error::Base64( + "base64url must not contain whitespace".into(), + )); + } + let padding = s.len() - s.trim_end_matches('=').len(); + let core = s.trim_end_matches('='); + if padding > 2 + || (padding > 0 && (s.len() & 3) != 0) + || core.len() % 4 == 1 + || !core + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err(Error::Base64("invalid base64url".into())); + } + let cleaned: String = core + .chars() + .map(|c| match c { + '-' => '+', + '_' => '/', + other => other, + }) + .collect(); + STANDARD_NO_PAD + .decode(cleaned.as_bytes()) + .map_err(|e| Error::Base64(e.to_string())) +} + +// ------------------------------------------------------------------------------------------------ +// Verification +// ------------------------------------------------------------------------------------------------ + +/// The outcome of low-level cryptographic verification. `valid` does not evaluate key lifecycle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Verification { + /// `true` iff Ed25519 mathematics, algorithm metadata, and key-id binding are valid. + /// This is not signer acceptance; use [`verify_with_registry`] for lifecycle policy. + pub valid: bool, + /// The `key_id` declared in the envelope's signature block. + pub key_id: String, + /// The declared algorithm (expected `"Ed25519"`). + pub alg: Option, + /// The declared canonicalization (expected `"json-sorted-compact"`). + pub canonicalization: Option, + /// `Some(true)` when an attached `anchor` was inside the verified signature scope, + /// `Some(false)` for the frozen legacy convention where it was attached after signing, + /// and `None` when no anchor is present or no signature form passed. + pub anchor_authenticated: Option, +} + +fn find<'a>(pairs: &'a [(String, Node)], key: &str) -> Option<&'a Node> { + // last occurrence wins, matching Python dict semantics + pairs.iter().rev().find(|(k, _)| k == key).map(|(_, v)| v) +} + +fn str_field(pairs: &[(String, Node)], key: &str) -> Option { + match find(pairs, key) { + Some(Node::Str(s)) => Some(s.clone()), + _ => None, + } +} + +struct SigBlock { + key_id: String, + sig_b64: String, + alg: Option, + canonicalization: Option, +} + +fn signature_block(pairs: &[(String, Node)]) -> Result { + let sig_node = find(pairs, "signature").ok_or(Error::NoSignature)?; + let sig_pairs = match sig_node { + Node::Object(p) => p, + _ => { + return Err(Error::BadSignatureBlock( + "\"signature\" is not an object".into(), + )) + } + }; + let key_id = str_field(sig_pairs, "key_id") + .ok_or_else(|| Error::BadSignatureBlock("missing key_id".into()))?; + let sig_b64 = str_field(sig_pairs, "sig") + .ok_or_else(|| Error::BadSignatureBlock("missing sig".into()))?; + Ok(SigBlock { + key_id, + sig_b64, + alg: str_field(sig_pairs, "alg"), + canonicalization: str_field(sig_pairs, "canonicalization"), + }) +} + +/// Return the `key_id` declared in an envelope's signature block (without verifying anything). +/// Useful for looking the key up in a [`Keys`] set first. +pub fn signature_key_id(envelope_json_text: &str) -> Result { + let pairs = parse_object(envelope_json_text)?; + Ok(signature_block(&pairs)?.key_id) +} + +/// Verify a signed Dynamic Feed envelope against an Ed25519 public key +/// (strict base64url, padded or not — as published at `/.well-known/keys`). +/// +/// Returns `Ok(Verification { valid: false, .. })` when the envelope is well-formed but the +/// signature does not match (e.g. tampered payload); returns `Err` only for structural problems. +/// Entirely offline — no network access. +pub fn verify_envelope(envelope_json_text: &str, pubkey_b64: &str) -> Result { + let pairs = parse_object(envelope_json_text)?; + let sig = signature_block(&pairs)?; + + let pk_bytes = b64_decode(pubkey_b64)?; + let pk_arr: [u8; 32] = pk_bytes .as_slice() .try_into() - .map_err(|_| "signature not 64 bytes")?; + .map_err(|_| Error::Key(format!("expected 32 bytes, got {}", pk_bytes.len())))?; + let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error::Key(e.to_string()))?; + let fingerprint = Sha256::digest(pk_arr); + let expected_key_id = format!( + "df-ed25519-{}", + fingerprint[..6] + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + + let sig_bytes = b64_decode(&sig.sig_b64)?; + let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| { + Error::BadSignatureBlock(format!("sig: expected 64 bytes, got {}", sig_bytes.len())) + })?; + let signature = Signature::from_bytes(&sig_arr); + + // Two anchor conventions coexist: some receipts sign the body BEFORE a timestamp anchor is + // attached (an RFC 3161 / DLT anchor is OF the signed hash, so it can only land after signing — + // e.g. /v1/answer, /v1/receipt), others sign WITH the anchor already inside the bytes (e.g. + // notary inclusion proofs). Try the strip-signature form first; if it fails and an `anchor` is + // present, retry with the anchor stripped too. Accepting either canonical form is not a + // weakening — a forgery still needs a valid Ed25519 signature over one of the two forms. + let metadata_valid = sig.alg.as_deref() == Some("Ed25519") + && sig.canonicalization.as_deref() == Some("json-sorted-compact") + && sig.key_id == expected_key_id; + let has_anchor = pairs.iter().any(|(k, _)| k == "anchor"); + let mut valid = metadata_valid + && vk + .verify(&canonical_string(&pairs).into_bytes(), &signature) + .is_ok(); + let mut anchor_authenticated = if valid && has_anchor { + Some(true) + } else { + None + }; + if metadata_valid && !valid && has_anchor { + let msg = canonical_string_dropping(&pairs, &["signature", "anchor"]).into_bytes(); + valid = vk.verify(&msg, &signature).is_ok(); + if valid { + anchor_authenticated = Some(false); + } + } + + Ok(Verification { + valid, + key_id: sig.key_id, + alg: sig.alg, + canonicalization: sig.canonicalization, + anchor_authenticated, + }) +} + +// ------------------------------------------------------------------------------------------------ +// Keys (the /.well-known/keys JWKS shape) +// ------------------------------------------------------------------------------------------------ + +/// The published key set from `GET https://dynamicfeed.ai/.well-known/keys`. +/// +/// On the wire this is a flat map — `{ "": "", "signature": {...} }` — +/// i.e. the keys document is itself a signed envelope. [`Keys::parse`] accepts that live shape +/// (top-level string fields, the `signature` block excluded) as well as the nested +/// `{ "keys": { "": "" } }` variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Keys { + map: BTreeMap, +} + +impl Keys { + /// Parse a keys document (see type-level docs for the accepted shapes). + pub fn parse(json_text: &str) -> Result { + let pairs = parse_object(json_text)?; + let source: &[(String, Node)] = match find(&pairs, "keys") { + Some(Node::Object(inner)) => inner, + _ => &pairs, + }; + let mut map = BTreeMap::new(); + for (k, v) in source { + if k == "signature" { + continue; + } + if let Node::Str(s) = v { + map.insert(k.clone(), s.clone()); + } + } + if map.is_empty() { + return Err(Error::Key( + "no public keys found in the keys document".into(), + )); + } + Ok(Keys { map }) + } + + /// Look up a public key (base64) by `key_id`. + pub fn get(&self, key_id: &str) -> Option<&str> { + self.map.get(key_id).map(|s| s.as_str()) + } + + /// All known key ids. + pub fn key_ids(&self) -> impl Iterator { + self.map.keys().map(|s| s.as_str()) + } + + /// Number of keys in the set. + pub fn len(&self) -> usize { + self.map.len() + } + + /// Whether the set is empty (never true for a successfully parsed document). + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Fetch and parse the key set from a URL (requires the `fetch` feature). + #[cfg(feature = "fetch")] + pub fn fetch(url: &str) -> Result { + let body = ureq::AgentBuilder::new() + .redirects(0) + .build() + .get(url) + .timeout(std::time::Duration::from_secs(20)) + .call() + .map_err(|e| Error::Fetch(e.to_string()))? + .into_string() + .map_err(|e| Error::Fetch(e.to_string()))?; + Keys::parse(&body) + } +} + +/// Fetch the published key set (defaults: [`DEFAULT_KEYS_URL`]). Requires the `fetch` feature. +#[cfg(feature = "fetch")] +pub fn fetch_keys(url: &str) -> Result { + Keys::fetch(url) +} + +// ------------------------------------------------------------------------------------------------ +// Signing-key lifecycle policy +// ------------------------------------------------------------------------------------------------ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LifecycleStatus { + Active, + Retired, + Compromised, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct LifecycleRegistry { + keys: Keys, + active_key_id: String, + statuses: BTreeMap, + registry_revision: u64, + effective_registry_floor: u64, + mode: VerificationMode, + registry_source_authenticated: bool, + highest_authenticated_registry_revision: Option, +} + +fn object_field<'a>(pairs: &'a [(String, Node)], key: &str) -> Result<&'a [(String, Node)], Error> { + match find(pairs, key) { + Some(Node::Object(inner)) => Ok(inner), + _ => Err(Error::Lifecycle(format!("{key} must be an object"))), + } +} + +fn bool_field(pairs: &[(String, Node)], key: &str) -> Option { + match find(pairs, key) { + Some(Node::Bool(value)) => Some(*value), + _ => None, + } +} + +fn positive_u64_field(pairs: &[(String, Node)], key: &str) -> Result { + match find(pairs, key) { + Some(Node::Num(value)) => value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| Error::Lifecycle(format!("{key} must be a positive integer"))), + _ => Err(Error::Lifecycle(format!( + "{key} must be a positive integer" + ))), + } +} + +fn null_field(pairs: &[(String, Node)], key: &str) -> bool { + matches!(find(pairs, key), Some(Node::Null)) +} + +fn utc_timestamp_key(value: &str) -> Option<(u32, u32, u32, u32, u32, u32, u32)> { + // Registry timestamps are canonical UTC RFC3339. Validate the calendar components rather than + // accepting normalizing parsers (e.g. February 30 -> March 2). + let bytes = value.as_bytes(); + if bytes.len() < 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || *bytes.last().unwrap_or(&0) != b'Z' + { + return None; + } + let parse = |a: usize, b: usize| value[a..b].parse::().ok(); + let (year, month, day, hour, minute, second) = match ( + parse(0, 4), + parse(5, 7), + parse(8, 10), + parse(11, 13), + parse(14, 16), + parse(17, 19), + ) { + (Some(y), Some(m), Some(d), Some(h), Some(min), Some(s)) => (y, m, d, h, min, s), + _ => return None, + }; + let suffix = &value[19..value.len() - 1]; + let nanos = if suffix.is_empty() { + 0 + } else { + let digits = suffix.strip_prefix('.')?; + if digits.is_empty() || digits.len() > 9 || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let value = digits.parse::().ok()?; + value * 10u32.pow((9 - digits.len()) as u32) + }; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let max_day = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return None, + }; + if day < 1 || day > max_day || hour > 23 || minute > 59 || second > 59 { + return None; + } + Some((year, month, day, hour, minute, second, nanos)) +} - let vk = VerifyingKey::from_bytes(&pk).map_err(|e| format!("bad key: {e}"))?; - let signature = Signature::from_bytes(&sg); - Ok(vk.verify(&canonical(env), &signature).is_ok()) +fn valid_utc_timestamp(value: &str) -> bool { + utc_timestamp_key(value).is_some() } -/// Parse with `arbitrary_precision` so numbers keep their exact source text. -pub fn parse(text: &str) -> Result { - serde_json::from_str(text).map_err(|e| format!("json: {e}")) +impl LifecycleRegistry { + pub fn parse(json_text: &str) -> Result { + Self::parse_with_options(json_text, RegistryValidationOptions::default()) + } + + pub fn parse_with_options( + json_text: &str, + options: RegistryValidationOptions, + ) -> Result { + let pairs = parse_object(json_text)?; + if str_field(&pairs, "schema").as_deref() != Some("df-signing-key-registry/v1") + || str_field(&pairs, "issuer").as_deref() != Some("dynamicfeed.ai") + { + return Err(Error::Lifecycle("unexpected schema or issuer".into())); + } + let registry_revision = positive_u64_field(&pairs, "registry_revision")?; + let effective_registry_floor = match options.mode { + VerificationMode::HistoricalSnapshot => 0, + VerificationMode::Live => COMPILED_MIN_REGISTRY_REVISION + .max(options.minimum_registry_revision.unwrap_or(0)) + .max(options.highest_authenticated_registry_revision.unwrap_or(0)), + }; + if options.mode == VerificationMode::Live && registry_revision < effective_registry_floor { + return Err(Error::Lifecycle(format!( + "registry revision {registry_revision} is below effective floor {effective_registry_floor}" + ))); + } + let updated = str_field(&pairs, "updated_at") + .ok_or_else(|| Error::Lifecycle("missing updated_at".into()))?; + if !valid_utc_timestamp(&updated) { + return Err(Error::Lifecycle( + "updated_at is not a valid UTC timestamp".into(), + )); + } + let active_key_id = str_field(&pairs, "active_key_id") + .ok_or_else(|| Error::Lifecycle("missing active_key_id".into()))?; + let public_pairs = object_field(&pairs, "public_keys")?; + let lifecycle_pairs = object_field(&pairs, "lifecycle")?; + let mut key_map = BTreeMap::new(); + for (key_id, node) in public_pairs { + let encoded = match node { + Node::Str(value) => value.clone(), + _ => return Err(Error::Lifecycle(format!("public key {key_id} is not text"))), + }; + let raw = b64_decode(&encoded)?; + if raw.len() != 32 { + return Err(Error::Lifecycle(format!( + "public key {key_id} is not 32 bytes" + ))); + } + let fingerprint = Sha256::digest(&raw); + let expected = format!( + "df-ed25519-{}", + fingerprint[..6] + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + if &expected != key_id { + return Err(Error::Lifecycle(format!( + "public key does not derive {key_id}" + ))); + } + key_map.insert(key_id.clone(), encoded); + } + if key_map.is_empty() || !key_map.contains_key(&active_key_id) { + return Err(Error::Lifecycle("active key is not published".into())); + } + let life_ids: BTreeSet<&str> = lifecycle_pairs.iter().map(|(id, _)| id.as_str()).collect(); + let key_ids: BTreeSet<&str> = key_map.keys().map(|id| id.as_str()).collect(); + if life_ids != key_ids { + return Err(Error::Lifecycle( + "every public key needs one lifecycle entry".into(), + )); + } + let mut statuses = BTreeMap::new(); + let mut active_count = 0; + for (key_id, node) in lifecycle_pairs { + let meta = match node { + Node::Object(value) => value, + _ => { + return Err(Error::Lifecycle(format!( + "lifecycle {key_id} is not an object" + ))) + } + }; + if str_field(meta, "alg").as_deref() != Some("Ed25519") + || str_field(meta, "use").as_deref() != Some("receipt-signing") + || bool_field(meta, "public_key_retained") != Some(true) + { + return Err(Error::Lifecycle(format!( + "invalid lifecycle metadata for {key_id}" + ))); + } + let raw = b64_decode(key_map.get(key_id).unwrap())?; + let fingerprint = Sha256::digest(&raw) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + if str_field(meta, "fingerprint_sha256").as_deref() != Some(fingerprint.as_str()) { + return Err(Error::Lifecycle(format!( + "fingerprint mismatch for {key_id}" + ))); + } + let first_used = str_field(meta, "first_used_at") + .and_then(|v| utc_timestamp_key(&v)) + .ok_or_else(|| Error::Lifecycle(format!("invalid first_used_at for {key_id}")))?; + let active_from = str_field(meta, "active_from") + .and_then(|v| utc_timestamp_key(&v)) + .ok_or_else(|| Error::Lifecycle(format!("invalid active_from for {key_id}")))?; + if first_used > active_from { + return Err(Error::Lifecycle(format!( + "first_used_at is after active_from for {key_id}" + ))); + } + let status = match str_field(meta, "status").as_deref() { + Some("active") => { + active_count += 1; + if key_id != &active_key_id + || !null_field(meta, "retired_at") + || !null_field(meta, "compromised_after") + { + return Err(Error::Lifecycle("active-key metadata mismatch".into())); + } + LifecycleStatus::Active + } + Some("retired") => { + let retired_at = + str_field(meta, "retired_at").and_then(|v| utc_timestamp_key(&v)); + if retired_at.is_none() + || active_from > retired_at.unwrap() + || !null_field(meta, "compromised_after") + { + return Err(Error::Lifecycle(format!("invalid retirement for {key_id}"))); + } + LifecycleStatus::Retired + } + Some("compromised") => { + let retired_at = + str_field(meta, "retired_at").and_then(|v| utc_timestamp_key(&v)); + let compromised_after = + str_field(meta, "compromised_after").and_then(|v| utc_timestamp_key(&v)); + if retired_at.is_none() + || compromised_after.is_none() + || active_from > retired_at.unwrap() + || compromised_after.unwrap() > retired_at.unwrap() + { + return Err(Error::Lifecycle(format!( + "invalid compromise metadata for {key_id}" + ))); + } + LifecycleStatus::Compromised + } + _ => { + return Err(Error::Lifecycle(format!( + "unknown lifecycle status for {key_id}" + ))) + } + }; + statuses.insert(key_id.clone(), status); + } + if active_count != 1 { + return Err(Error::Lifecycle( + "registry must contain exactly one active key".into(), + )); + } + let authority = object_field(&pairs, "authority")?; + if bool_field(authority, "independent_registry_root") != Some(false) { + return Err(Error::Lifecycle( + "authority classification is missing".into(), + )); + } + let policy = object_field(&pairs, "verification_policy")?; + for name in [ + "cryptographic_validity_separate_from_lifecycle", + "retired_keys_verify_historical_bytes", + "compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp", + "receipt_issued_at_alone_is_not_independent_time_evidence", + ] { + if bool_field(policy, name) != Some(true) { + return Err(Error::Lifecycle("verification policy is incomplete".into())); + } + } + Ok(Self { + keys: Keys { map: key_map }, + active_key_id, + statuses, + registry_revision, + effective_registry_floor, + mode: options.mode, + registry_source_authenticated: options.registry_source_authenticated, + highest_authenticated_registry_revision: options + .highest_authenticated_registry_revision, + }) + } + + pub fn registry_revision(&self) -> u64 { + self.registry_revision + } + + pub fn effective_registry_floor(&self) -> u64 { + self.effective_registry_floor + } + + pub fn mode(&self) -> VerificationMode { + self.mode + } + + #[cfg(feature = "fetch")] + pub fn fetch(url: &str) -> Result { + let options = RegistryValidationOptions { + registry_source_authenticated: url == DEFAULT_LIFECYCLE_URL, + ..RegistryValidationOptions::default() + }; + Self::fetch_with_options(url, options) + } + + #[cfg(feature = "fetch")] + pub fn fetch_with_options( + url: &str, + options: RegistryValidationOptions, + ) -> Result { + let body = ureq::AgentBuilder::new() + .redirects(0) + .build() + .get(url) + .set("Cache-Control", "no-cache") + .timeout(std::time::Duration::from_secs(20)) + .call() + .map_err(|e| Error::Fetch(e.to_string()))? + .into_string() + .map_err(|e| Error::Fetch(e.to_string()))?; + Self::parse_with_options(&body, options) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyVerification { + pub accepted: bool, + pub crypto_valid: bool, + pub signer_accepted: bool, + pub policy_as_of_accepted: bool, + pub lifecycle_status: LifecycleStatus, + pub key_id: String, + /// Signature coverage of an attached anchor; see [`Verification::anchor_authenticated`]. + pub anchor_authenticated: Option, + pub mode: VerificationMode, + pub historical_snapshot: bool, + pub registry_revision: u64, + pub effective_registry_floor: u64, + pub registry_source_authenticated: bool, + pub rollback_protected: bool, +} + +pub fn verify_with_registry( + envelope_json_text: &str, + registry: &LifecycleRegistry, +) -> Result { + let key_id = signature_key_id(envelope_json_text)?; + let pubkey = registry + .keys + .get(&key_id) + .ok_or_else(|| Error::UnknownKeyId(key_id.clone()))?; + let crypto = verify_envelope(envelope_json_text, pubkey)?; + let status = registry + .statuses + .get(&key_id) + .cloned() + .unwrap_or(LifecycleStatus::Unknown); + let (signer_accepted, policy_as_of_accepted) = match registry.mode { + VerificationMode::Live => ( + status == LifecycleStatus::Active + && registry.active_key_id == key_id + && registry.registry_source_authenticated, + false, + ), + VerificationMode::HistoricalSnapshot => ( + false, + matches!(status, LifecycleStatus::Active | LifecycleStatus::Retired), + ), + }; + let rollback_protected = registry.mode == VerificationMode::Live + && registry.registry_source_authenticated + && registry.highest_authenticated_registry_revision.is_some() + && registry.registry_revision + >= registry + .highest_authenticated_registry_revision + .unwrap_or(u64::MAX); + Ok(PolicyVerification { + accepted: crypto.valid && signer_accepted, + crypto_valid: crypto.valid, + signer_accepted, + policy_as_of_accepted, + lifecycle_status: status, + key_id, + anchor_authenticated: crypto.anchor_authenticated, + mode: registry.mode, + historical_snapshot: registry.mode == VerificationMode::HistoricalSnapshot, + registry_revision: registry.registry_revision, + effective_registry_floor: registry.effective_registry_floor, + registry_source_authenticated: registry.registry_source_authenticated, + rollback_protected, + }) +} + +#[cfg(feature = "fetch")] +pub fn fetch_lifecycle_registry(url: &str) -> Result { + LifecycleRegistry::fetch(url) +} + +/// Verify cryptographic signature mathematics by looking `key_id` up in a flat [`Keys`] set. +/// This does not evaluate lifecycle status; use [`verify_with_registry`] for policy acceptance. +pub fn verify_with_keys(envelope_json_text: &str, keys: &Keys) -> Result { + let key_id = signature_key_id(envelope_json_text)?; + let pubkey = keys + .get(&key_id) + .ok_or_else(|| Error::UnknownKeyId(key_id.clone()))?; + verify_envelope(envelope_json_text, pubkey) +} + +// ------------------------------------------------------------------------------------------------ +// Unit tests for the two canonicalization traps +// ------------------------------------------------------------------------------------------------ + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a JSON `\uXXXX` escape sequence at runtime (backslash + "u" + hex digits). + fn u(hex: &str) -> String { + format!("{}u{}", '\\', hex) + } + + #[test] + fn ensure_ascii_escaping_matches_python() { + // python3: json.dumps({"a":"°C – ok","b":True}, sort_keys=True, separators=(",",":")) + // escapes ° (U+00B0) and – (U+2013) to ASCII \uXXXX sequences — raw UTF-8 in, + // ensure_ascii escapes out. + let src = r#"{"b": true, "a": "°C – ok", "signature": {"sig": "x", "key_id": "k"}}"#; + let got = canonical_bytes(src).unwrap(); + let expected = format!(r#"{{"a":"{}C {} ok","b":true}}"#, u("00b0"), u("2013")); + assert_eq!(String::from_utf8(got).unwrap(), expected); + } + + #[test] + fn astral_chars_become_surrogate_pair_escapes() { + // python3: json.dumps({"r":"🤖"}) escapes U+1F916 to the surrogate pair d83e/dd16. + let expected = format!(r#"{{"r":"{}{}"}}"#, u("d83e"), u("dd16")); + // raw UTF-8 emoji in the source... + let got = canonical_bytes(r#"{"r":"🤖"}"#).unwrap(); + assert_eq!(String::from_utf8(got).unwrap(), expected); + // ...and the same when the source already uses the escaped surrogate pair + let src_escaped = format!(r#"{{"r":"{}{}"}}"#, u("d83e"), u("dd16")); + let got2 = canonical_bytes(&src_escaped).unwrap(); + assert_eq!(String::from_utf8(got2).unwrap(), expected); + } + + #[test] + fn numbers_round_trip_verbatim() { + // serde_json would re-print 1e-07 as 1e-7 and -50.0 as -50.0/-50 depending on type; + // we must keep the source spelling EXACTLY (Python repr round-trip). + let src = r#"{"z": 1e-07, "a": -50.0, "m": 16.7, "n": 100000000000000000000}"#; + let got = canonical_bytes(src).unwrap(); + assert_eq!( + String::from_utf8(got).unwrap(), + r#"{"a":-50.0,"m":16.7,"n":100000000000000000000,"z":1e-07}"# + ); + } + + #[test] + fn signature_field_is_stripped_and_keys_sorted() { + let src = r#"{"b":2,"signature":{"sig":"s","key_id":"k"},"a":1}"#; + let got = canonical_bytes(src).unwrap(); + assert_eq!(String::from_utf8(got).unwrap(), r#"{"a":1,"b":2}"#); + } + + #[test] + fn nonstandard_numbers_and_raw_controls_fail_closed() { + for invalid in [r#"{"x":+1}"#, r#"{"x":01}"#, r#"{"x":1.}"#, r#"{"x":1e}"#] { + assert!( + canonical_bytes(invalid).is_err(), + "accepted invalid JSON: {invalid}" + ); + } + let raw_control = format!("{{\"x\":\"{}\"}}", '\u{0001}'); + assert!(canonical_bytes(&raw_control).is_err()); + } } diff --git a/clients/rust/src/main.rs b/clients/rust/src/main.rs index 40443fd..37681cf 100644 --- a/clients/rust/src/main.rs +++ b/clients/rust/src/main.rs @@ -1,53 +1,192 @@ -//! CLI for the DF-VERIFY/1 Rust reference verifier. +//! dynamicfeed-verify — verify DF-VERIFY/1 signature bytes and signer lifecycle policy. //! -//! dynamicfeed-verify verify a saved signed response -//! dynamicfeed-verify --canonical print the canonical bytes +//! Usage: +//! dynamicfeed-verify [FILE] --registry +//! dynamicfeed-verify [FILE] --registry-url (requires the `fetch` feature) +//! dynamicfeed-verify [FILE] --key --crypto-only //! -//! Keys are the `key_id -> base64url public key` map from -//! https://dynamicfeed.ai/.well-known/keys (fetch with curl; the verifier itself -//! has no network dependency, so you can verify fully offline). -use dynamicfeed_verify::{canonical, parse, verify}; -use std::{env, fs, process}; - -fn main() { - let args: Vec = env::args().collect(); - if args.len() == 3 && args[1] == "--canonical" { - let v = parse(&read(&args[2])).unwrap_or_else(die); - print!("{}", String::from_utf8_lossy(&canonical(&v))); - return; +//! FILE defaults to "-" (stdin). With the `fetch` feature and no --key/--keys-url, +//! lifecycle policy is fetched from the no-store signing-key registry. +//! Flat key input is mathematical verification only and requires explicit `--crypto-only`. + +use std::io::Read; +use std::process::ExitCode; + +const USAGE: &str = "usage: dynamicfeed-verify [FILE|-] [--registry FILE | --registry-url URL]\n\ + [--historical-snapshot] [--minimum-registry-revision N]\n\ + [--highest-authenticated-registry-revision N] [--registry-source-authenticated]\n\ + dynamicfeed-verify [FILE|-] (--key PUBKEY | --keys-url URL) --crypto-only\n\ + policy mode exits 0 only when signature + lifecycle pass. crypto-only mode\n\ + is explicit and never claims signer lifecycle acceptance."; + +fn positive_revision(value: String, name: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| format!("{name} must be a positive integer")) +} + +fn run() -> Result { + let mut file: Option = None; + let mut key: Option = None; + let mut keys_url: Option = None; + let mut registry_file: Option = None; + let mut registry_url: Option = None; + let mut crypto_only = false; + let mut verification_mode = dynamicfeed_verify::VerificationMode::Live; + let mut minimum_registry_revision: Option = None; + let mut highest_authenticated_registry_revision: Option = None; + let mut registry_source_authenticated = false; + + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--key" => key = Some(args.next().ok_or("--key needs a value")?), + "--keys-url" => keys_url = Some(args.next().ok_or("--keys-url needs a value")?), + "--registry" => registry_file = Some(args.next().ok_or("--registry needs a value")?), + "--registry-url" => { + registry_url = Some(args.next().ok_or("--registry-url needs a value")?) + } + "--crypto-only" => crypto_only = true, + "--historical-snapshot" => { + verification_mode = dynamicfeed_verify::VerificationMode::HistoricalSnapshot + } + "--minimum-registry-revision" => { + minimum_registry_revision = Some(positive_revision( + args.next() + .ok_or("--minimum-registry-revision needs a value")?, + "--minimum-registry-revision", + )?) + } + "--highest-authenticated-registry-revision" => { + highest_authenticated_registry_revision = Some(positive_revision( + args.next() + .ok_or("--highest-authenticated-registry-revision needs a value")?, + "--highest-authenticated-registry-revision", + )?) + } + "--registry-source-authenticated" => registry_source_authenticated = true, + "-h" | "--help" => { + println!("{USAGE}"); + std::process::exit(0); + } + _ if file.is_none() => file = Some(a), + other => return Err(format!("unexpected argument '{other}'\n{USAGE}")), + } } - if args.len() == 3 { - let env_v = parse(&read(&args[1])).unwrap_or_else(die); - let keys_v = parse(&read(&args[2])).unwrap_or_else(die); - let keys = match keys_v.as_object() { - Some(m) => m.clone(), - None => die("keys file is not a JSON object".into()), - }; - match verify(&env_v, &keys) { - Ok(true) => { - println!( - "VERIFIED: signature is valid (bytes intact). Note: signed is not verified." - ); - process::exit(0); + + let text = match file.as_deref() { + None | Some("-") => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| format!("could not read stdin — {e}"))?; + buf + } + Some(path) => { + std::fs::read_to_string(path).map_err(|e| format!("could not read {path} — {e}"))? + } + }; + + if key.is_some() || keys_url.is_some() { + if !crypto_only { + return Err("flat key input has no lifecycle policy; add --crypto-only to request only Ed25519 mathematics, or supply --registry".into()); + } + let verification = if let Some(pubkey) = key { + dynamicfeed_verify::verify_envelope(&text, &pubkey).map_err(|e| e.to_string())? + } else { + #[cfg(feature = "fetch")] + { + let keys = dynamicfeed_verify::fetch_keys(keys_url.as_deref().unwrap()) + .map_err(|e| e.to_string())?; + dynamicfeed_verify::verify_with_keys(&text, &keys).map_err(|e| e.to_string())? } - Ok(false) => { - eprintln!("INVALID: signature did not verify (data may be altered)."); - process::exit(1); + #[cfg(not(feature = "fetch"))] + { + return Err("this build has no network support for --keys-url".into()); } - Err(e) => die(e), + }; + if verification.valid { + println!( + "CRYPTO_VALID LIFECYCLE_UNCHECKED key_id={}", + verification.key_id + ); + return Ok(true); } + println!("CRYPTO_INVALID key_id={}", verification.key_id); + return Ok(false); } - eprintln!( - "usage:\n dynamicfeed-verify \n dynamicfeed-verify --canonical " - ); - process::exit(2); -} -fn read(p: &str) -> String { - fs::read_to_string(p).unwrap_or_else(|e| die(format!("read {p}: {e}"))) + let options = dynamicfeed_verify::RegistryValidationOptions { + mode: verification_mode, + minimum_registry_revision, + highest_authenticated_registry_revision, + registry_source_authenticated, + }; + let registry = if let Some(path) = registry_file { + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("could not read lifecycle registry {path} — {e}"))?; + dynamicfeed_verify::LifecycleRegistry::parse_with_options(&raw, options) + .map_err(|e| e.to_string())? + } else { + #[cfg(feature = "fetch")] + { + let url = registry_url + .unwrap_or_else(|| dynamicfeed_verify::DEFAULT_LIFECYCLE_URL.to_string()); + let fetch_options = dynamicfeed_verify::RegistryValidationOptions { + registry_source_authenticated: registry_source_authenticated + || url == dynamicfeed_verify::DEFAULT_LIFECYCLE_URL, + ..options + }; + dynamicfeed_verify::LifecycleRegistry::fetch_with_options(&url, fetch_options) + .map_err(|e| e.to_string())? + } + #[cfg(not(feature = "fetch"))] + { + let _ = registry_url; + return Err(format!( + "this build has no network support — pass --registry FILE, or rebuild \ + with `cargo build --features fetch` to use --registry-url\n{USAGE}" + )); + } + }; + let verification = + dynamicfeed_verify::verify_with_registry(&text, ®istry).map_err(|e| e.to_string())?; + if verification.accepted { + println!( + "POLICY_ACCEPTED key_id={} lifecycle={:?} registry_revision={} rollback_protected={} anchor_authenticated={:?}", + verification.key_id, + verification.lifecycle_status, + verification.registry_revision, + verification.rollback_protected, + verification.anchor_authenticated + ); + Ok(true) + } else if verification.crypto_valid { + println!( + "CRYPTO_VALID LIFECYCLE_REJECTED key_id={} lifecycle={:?} historical_snapshot={} policy_as_of_accepted={} registry_revision={} anchor_authenticated={:?}", + verification.key_id, + verification.lifecycle_status, + verification.historical_snapshot, + verification.policy_as_of_accepted, + verification.registry_revision, + verification.anchor_authenticated + ); + Ok(false) + } else { + println!("CRYPTO_INVALID key_id={}", verification.key_id); + Ok(false) + } } -fn die(msg: String) -> T { - eprintln!("error: {msg}"); - process::exit(2); +fn main() -> ExitCode { + match run() { + Ok(true) => ExitCode::from(0), + Ok(false) => ExitCode::from(1), + Err(msg) => { + eprintln!("error: {msg}"); + ExitCode::from(1) + } + } } diff --git a/clients/rust/tests/conformance.rs b/clients/rust/tests/conformance.rs index 6056dee..fd4341e 100644 --- a/clients/rust/tests/conformance.rs +++ b/clients/rust/tests/conformance.rs @@ -1,38 +1,173 @@ -//! Runs the language-agnostic DF-VERIFY/1 vectors (../../tests/vectors) against -//! the Rust verifier. Same vectors the Python and JS harnesses use. -use dynamicfeed_verify::{canonical, parse, verify}; -use std::path::PathBuf; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use dynamicfeed_verify::{ + canonical_bytes, verify_envelope, verify_with_registry, LifecycleRegistry, LifecycleStatus, + RegistryValidationOptions, VerificationMode, +}; +use ed25519_dalek::{Signer, SigningKey}; +use sha2::{Digest, Sha256}; -fn vec_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors") -} +const AWARENESS_ENVELOPE: &str = include_str!("fixtures/awareness_noanchor.json"); +const AWARENESS_PUBKEY: &str = include_str!("fixtures/awareness_pubkey.txt"); +const ANSWER_ENVELOPE: &str = include_str!("fixtures/answer_anchored.json"); +const ANSWER_PUBKEY: &str = include_str!("fixtures/answer_pubkey.txt"); +const LIFECYCLE_REGISTRY: &str = include_str!("fixtures/signing-key-lifecycle.json"); -#[test] -fn canonicalization_vectors_match_byte_for_byte() { - let raw = std::fs::read_to_string(vec_dir().join("canonicalization.json")).unwrap(); - let cv: serde_json::Value = serde_json::from_str(&raw).unwrap(); - for v in cv["vectors"].as_array().unwrap() { - let got = String::from_utf8(canonical(&v["payload"])).unwrap(); - let exp = v["canonical"].as_str().unwrap(); - assert_eq!(got, exp, "canonical mismatch for vector: {}", v["name"]); - } +fn generated_active_material() -> (String, String) { + let signing = SigningKey::from_bytes(&[7u8; 32]); + let public = signing.verifying_key().to_bytes(); + let fingerprint = Sha256::digest(public) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let key_id = format!("df-ed25519-{}", &fingerprint[..12]); + let signature = URL_SAFE_NO_PAD.encode( + signing + .sign(br#"{"schema":"test/v1","value":1}"#) + .to_bytes(), + ); + let public_b64 = URL_SAFE_NO_PAD.encode(public); + let envelope = format!( + r#"{{"schema":"test/v1","value":1,"signature":{{"alg":"Ed25519","key_id":"{key_id}","canonicalization":"json-sorted-compact","sig":"{signature}"}}}}"# + ); + let registry = format!( + r#"{{ + "schema":"df-signing-key-registry/v1","issuer":"dynamicfeed.ai", + "registry_revision":1,"updated_at":"2026-07-11T10:00:00Z", + "active_key_id":"{key_id}","public_keys":{{"{key_id}":"{public_b64}"}}, + "lifecycle":{{"{key_id}":{{"alg":"Ed25519","use":"receipt-signing", + "fingerprint_sha256":"{fingerprint}","status":"active","public_key_retained":true, + "first_used_at":"2026-07-11T10:00:00Z","active_from":"2026-07-11T10:00:00Z", + "retired_at":null,"compromised_after":null}}}}, + "authority":{{"independent_registry_root":false}}, + "verification_policy":{{"cryptographic_validity_separate_from_lifecycle":true, + "retired_keys_verify_historical_bytes":true, + "compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp":true, + "receipt_issued_at_alone_is_not_independent_time_evidence":true}} + }}"# + ); + (envelope, registry) } #[test] -fn authentic_verifies_and_tampered_is_rejected() { - let raw = std::fs::read_to_string(vec_dir().join("signed-awareness.json")).unwrap(); - let sv: serde_json::Value = serde_json::from_str(&raw).unwrap(); - let keys = sv["public_keys"].as_object().unwrap(); +fn compromised_fixture_is_crypto_valid_but_policy_rejected() { + assert!( + verify_envelope(AWARENESS_ENVELOPE, AWARENESS_PUBKEY.trim()) + .unwrap() + .valid + ); - let authentic = parse(sv["authentic"]["envelope_text"].as_str().unwrap()).unwrap(); - let tampered = parse(sv["tampered"]["envelope_text"].as_str().unwrap()).unwrap(); + let registry = LifecycleRegistry::parse(LIFECYCLE_REGISTRY).unwrap(); + let result = verify_with_registry(AWARENESS_ENVELOPE, ®istry).unwrap(); + assert!(result.crypto_valid); + assert!(!result.accepted && !result.signer_accepted); + assert_eq!(result.lifecycle_status, LifecycleStatus::Compromised); +} +#[test] +fn tampered_fixture_is_crypto_invalid() { + let tampered = AWARENESS_ENVELOPE.replacen("0077b6e019464cfe", "1077b6e019464cfe", 1); + assert_ne!(tampered, AWARENESS_ENVELOPE); assert!( - verify(&authentic, keys).unwrap(), - "authentic envelope must verify" + !verify_envelope(&tampered, AWARENESS_PUBKEY.trim()) + .unwrap() + .valid + ); +} + +#[test] +fn legacy_attached_anchor_is_not_authenticated_by_signature() { + let modified_anchor = ANSWER_ENVELOPE.replacen( + "\"status\":\"self_anchor\"", + "\"status\":\"modified_by_third_party\"", + 1, ); + assert_ne!(modified_anchor, ANSWER_ENVELOPE); + for envelope in [ANSWER_ENVELOPE, modified_anchor.as_str()] { + let result = verify_envelope(envelope, ANSWER_PUBKEY.trim()).unwrap(); + assert!(result.valid); + assert_eq!(result.anchor_authenticated, Some(false)); + } + let tampered = ANSWER_ENVELOPE.replacen("ans_1cfb63d0", "ans_Xcfb63d0", 1); + assert_ne!(tampered, ANSWER_ENVELOPE); assert!( - !verify(&tampered, keys).unwrap(), - "tampered envelope must be rejected" + !verify_envelope(&tampered, ANSWER_PUBKEY.trim()) + .unwrap() + .valid + ); +} + +#[test] +fn live_active_key_passes_with_authenticated_revision_floor() { + let (envelope, registry_json) = generated_active_material(); + let unauthenticated = LifecycleRegistry::parse(®istry_json).unwrap(); + let unauthenticated_result = verify_with_registry(&envelope, &unauthenticated).unwrap(); + assert!(unauthenticated_result.crypto_valid); + assert!(!unauthenticated_result.accepted); + assert!(!unauthenticated_result.signer_accepted); + assert!(!unauthenticated_result.registry_source_authenticated); + assert!(!unauthenticated_result.rollback_protected); + let registry = LifecycleRegistry::parse_with_options( + ®istry_json, + RegistryValidationOptions { + highest_authenticated_registry_revision: Some(1), + registry_source_authenticated: true, + ..RegistryValidationOptions::default() + }, + ) + .unwrap(); + let result = verify_with_registry(&envelope, ®istry).unwrap(); + assert!(result.crypto_valid && result.signer_accepted && result.accepted); + assert!(result.rollback_protected); + assert_eq!(result.registry_revision, 1); +} + +#[test] +fn live_revision_rollback_is_rejected() { + let (_, registry_json) = generated_active_material(); + let error = LifecycleRegistry::parse_with_options( + ®istry_json, + RegistryValidationOptions { + minimum_registry_revision: Some(2), + ..RegistryValidationOptions::default() + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("below effective floor 2")); +} + +#[test] +fn historical_snapshot_is_explicit_and_non_actionable() { + let (envelope, registry_json) = generated_active_material(); + let registry = LifecycleRegistry::parse_with_options( + ®istry_json, + RegistryValidationOptions { + mode: VerificationMode::HistoricalSnapshot, + registry_source_authenticated: true, + ..RegistryValidationOptions::default() + }, + ) + .unwrap(); + let result = verify_with_registry(&envelope, ®istry).unwrap(); + assert!(result.crypto_valid && result.policy_as_of_accepted); + assert!(!result.accepted && !result.signer_accepted); + assert!(result.historical_snapshot); +} + +#[test] +fn duplicate_keys_and_rewritten_signature_metadata_fail_closed() { + assert!(canonical_bytes(r#"{"schema":"test/v1","schema":"attacker/v1"}"#).is_err()); + let (envelope, _) = generated_active_material(); + let public = URL_SAFE_NO_PAD.encode( + SigningKey::from_bytes(&[7u8; 32]) + .verifying_key() + .to_bytes(), ); + let rewritten = envelope.replacen("\"Ed25519\"", "\"ed25519\"", 1); + assert!(!verify_envelope(&rewritten, &public).unwrap().valid); + let signature_start = envelope.find("\"sig\":\"").unwrap() + 7; + let signature_end = signature_start + envelope[signature_start..].find('"').unwrap(); + let mut malformed = envelope.clone(); + malformed.replace_range(signature_start..signature_end, "!!!!"); + assert!(verify_envelope(&malformed, &public).is_err()); } diff --git a/clients/rust/tests/fixtures/answer_anchored.json b/clients/rust/tests/fixtures/answer_anchored.json new file mode 100644 index 0000000..f966aa8 --- /dev/null +++ b/clients/rust/tests/fixtures/answer_anchored.json @@ -0,0 +1 @@ +{"schema":"answer/v1","receipt_id":"ans_1cfb63d0e98f3769","generated_at":"2026-07-09T10:22:15Z","issuer":"dynamicfeed.ai","verdict":"outside_evidence_coverage","confidence":0.0,"confidence_rule":"answer-conf/1: supported/evidenced = min of check confidences over required checks; contradicted = max of check confidences over contradicting required checks; insufficient_evidence = 0.0; check confidence = min of fact.verification.confidence over its cited facts (fact-conf/1, the facts.py composite, every signal exposed). Weakest premise caps a conjunction; the best-evidenced counterexample sets a contradiction; no independence assumption is smuggled in by multiplying.","unrouted":["what is the current bitcoin block height?"],"coverage":{"schema":"answer-coverage/v1","router_version":"route/1","router_sha256":"3aeca61003008b23db886d2ffdf857b38b413eff12daef4a8986bedc4a16adff","families":[{"route_id":"software.version","ask":["is the latest python 3.13?","what is the latest version of node?"],"tool":"software_version"},{"route_id":"market.status","ask":["is the NASDAQ open?","the US market is open"],"tool":"market_hours"},{"route_id":"weather.city","ask":["what's the weather in Sydney?"],"tool":"current_weather"},{"route_id":"air_quality.city","ask":["what's the air quality in Delhi?"],"tool":"air_quality"},{"route_id":"treasury.yield","ask":["what is the 10 year treasury yield?"],"tool":"treasury_yields"}],"not_covered":["opinions and predictions","historical states","asset prices in question mode","any subject without a named live source in the tool catalog"],"refusal_contract":"questions outside these families return a SIGNED outside_evidence_coverage refusal; this endpoint never guesses","checks_mode":"structured checks reach ALL live tools directly and skip question parsing entirely; see GET /v1/answer usage + GET /tools.json"},"note":"No deterministic route from this text to a live evidence source exists in route/1. Dynamic Feed answers only what it can evidence; it never guesses. Structured checks reach all live tools directly (see GET /v1/answer).","principle":"signed proves integrity, not truth; verified is reserved for 2+ independent sources agreeing on this reading while fresh","advisory":true,"disclaimer":"ADVISORY EVIDENCE ONLY: a signed, timestamped record of what the named public sources reported at issue time. Not a certification and not a guarantee the underlying claim is true in the world. Tamper-evident, not tamper-proof.","served_by":{"provider":"Dynamic Feed","url":"https://dynamicfeed.ai","verify":"https://dynamicfeed.ai/.well-known/keys","facts":"https://dynamicfeed.ai/v1/facts","mcp":"https://dynamicfeed.ai/mcp","docs":"https://dynamicfeed.ai/llms.txt"},"signature":{"alg":"Ed25519","key_id":"df-ed25519-4cb32e72f333","canonicalization":"json-sorted-compact","sig":"Szc-rsKecelnsQRBNw-fyWMo9LDpZPf97vvlITSh4Ha6_PLAXVw6Km5J0G4Kr18zyXiaDPXOWDhjesXMUYYuAA=="},"anchor":{"status":"self_anchor","digest_sha256":"a0776acaa675c6717d226dd8f1da3633469675b0dfab349885cd1212512882c0","how":"save the receipt without its `anchor` field as canonical JSON (keys sorted, compact separators, UTF-8), then: openssl ts -query -data receipt.json -sha256 -cert | curl -s -H 'Content-Type: application/timestamp-query' --data-binary @- https://freetsa.org/tsr > receipt.tsr","note":"any holder can obtain RFC 3161 tokens from any public TSA at any time; Dynamic Feed is not a dependency for proof-of-when. Inline anchoring is available with an API key (anchor: true)."}} diff --git a/clients/rust/tests/fixtures/answer_pubkey.txt b/clients/rust/tests/fixtures/answer_pubkey.txt new file mode 100644 index 0000000..6af663e --- /dev/null +++ b/clients/rust/tests/fixtures/answer_pubkey.txt @@ -0,0 +1 @@ +O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk= diff --git a/clients/rust/tests/fixtures/awareness_noanchor.json b/clients/rust/tests/fixtures/awareness_noanchor.json new file mode 100644 index 0000000..2b3a98a --- /dev/null +++ b/clients/rust/tests/fixtures/awareness_noanchor.json @@ -0,0 +1 @@ +{"snapshot_id":"0077b6e019464cfe9c546bf639073e55","issued_at":"2026-07-09T10:22:16Z","issuer":"dynamicfeed.ai","schema":"awareness/v1","robot":{"id":null,"class":"aerial"},"location":{"lat":51.5,"lon":-0.12,"alt_m":null},"verdict":{"status":"go","reasons":["all checked conditions within limits"],"advisory":"Clear to proceed, all checked conditions within limits."},"facts":[{"id":"wx.wind_kmh","domain":"weather","value":5.8,"unit":"km/h","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.precip_mm","domain":"weather","value":0.0,"unit":"mm","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.temp_c","domain":"weather","value":28.5,"unit":"°C","observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"wx.conditions","domain":"weather","value":"Mainly clear","unit":null,"observed_at":"2026-07-09T11:15+01:00","age_s":436,"source":"Open-Meteo","source_url":"https://open-meteo.com","confidence":0.95,"stale":false},{"id":"sw.kp_index","domain":"space","value":2.33,"unit":"Kp","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"sw.storm_g","domain":"space","value":0,"unit":"G-scale","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"sw.storm_s","domain":"space","value":0,"unit":"S-scale","observed_at":"2026-07-09T06:00:00","age_s":15736,"source":"NOAA SWPC","source_url":"https://www.swpc.noaa.gov","confidence":0.95,"stale":false},{"id":"aq.us_aqi","domain":"air_quality","value":35,"unit":"US AQI","observed_at":"2026-07-09T11:00+01:00","age_s":1336,"source":"Open-Meteo Air Quality","source_url":"https://open-meteo.com","confidence":0.95,"stale":false,"category":"Good"}],"freshness":{"min_age_s":436,"max_age_s":15736,"stalest_field":"sw.kp_index"},"degraded":false,"degraded_sources":[],"note":"awareness/v1 (Rung A+B): signed verdict + grounded facts over live feeds; never blocks (hard deadline → degraded; fail-safe floors to caution, incl. on stale safety data). Verify the Ed25519 signature against GET /.well-known/keys (see scripts/verify_awareness.py). Public chain-anchoring of the signature is Rung C.","advisory":true,"disclaimer":"ADVISORY EVIDENCE ONLY: a signed, timestamped snapshot of what public data showed at issue time. This is NOT a safety certification, safety system or safety function, and NOT a guarantee that acting is safe. Your control stack owns every action and must apply its own safety logic. The signature proves this snapshot's existence and integrity, not the safety of any decision made from it.","signature":{"alg":"Ed25519","key_id":"df-ed25519-4cb32e72f333","canonicalization":"json-sorted-compact","sig":"b2XlAHX2TASu-2hErXorzs3_xZMlbKOpK8Xw8pA06db4XPwIR-AOQpNdFhm0zwR_v-VZCz0zG8RcqQQsiQNKCg=="}} diff --git a/clients/rust/tests/fixtures/awareness_pubkey.txt b/clients/rust/tests/fixtures/awareness_pubkey.txt new file mode 100644 index 0000000..6af663e --- /dev/null +++ b/clients/rust/tests/fixtures/awareness_pubkey.txt @@ -0,0 +1 @@ +O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk= diff --git a/clients/rust/tests/fixtures/signing-key-lifecycle.json b/clients/rust/tests/fixtures/signing-key-lifecycle.json new file mode 100644 index 0000000..0645084 --- /dev/null +++ b/clients/rust/tests/fixtures/signing-key-lifecycle.json @@ -0,0 +1,70 @@ +{ + "schema": "df-signing-key-registry/v1", + "issuer": "dynamicfeed.ai", + "registry_revision": 1, + "updated_at": "2026-07-11T10:17:26Z", + "active_key_id": "df-ed25519-6ca0de29113b", + "public_keys": { + "df-ed25519-4cb32e72f333": "O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk=", + "df-ed25519-6ca0de29113b": "acMWky59eQfoVqCUgmUM3tcl35YioRngL4wVDIQsstg=" + }, + "lifecycle": { + "df-ed25519-4cb32e72f333": { + "alg": "Ed25519", + "use": "receipt-signing", + "fingerprint_sha256": "4cb32e72f33309dcfef9e8a887a389463bf8cbe607104c48c1555e8881d0a119", + "status": "compromised", + "custody_class": "railway-environment-seed", + "first_used_at": "2026-06-06T00:00:00Z", + "active_from": "2026-06-06T00:00:00Z", + "retired_at": "2026-07-11T09:49:41Z", + "compromised_after": "2026-07-11T00:00:00Z", + "compromise_time_basis": "Conservative start-of-UTC-day boundary for the known exposure incident; the exact exposure instant is not claimed.", + "public_key_retained": true + }, + "df-ed25519-6ca0de29113b": { + "alg": "Ed25519", + "use": "receipt-signing", + "fingerprint_sha256": "6ca0de29113bb8dd8806a56c00f19c626320b8f6309e34a6c49921b10f8445cd", + "status": "active", + "custody_class": "railway-environment-seed", + "first_used_at": "2026-07-11T09:49:41Z", + "active_from": "2026-07-11T09:49:41Z", + "retired_at": null, + "compromised_after": null, + "public_key_retained": true + } + }, + "authority": { + "classification": "domain-authenticated-operational-disclosure", + "normative": false, + "independent_registry_root": false, + "note": "The HTTP response is signed by the active data-signing key. That proves the current Dynamic Feed signer made this disclosure; it is not an independent trust root. Live-policy consumers must authenticate updates and retain a monotonic minimum registry_revision outside this document." + }, + "rollback_protection": { + "live_policy_minimum_revision": 1, + "live_policy_requires_authenticated_update": true, + "historical_snapshot_mode_must_be_explicit": true, + "self_asserted_revision_is_not_anti_rollback": true + }, + "compatibility": { + "legacy_key_set": "/.well-known/keys", + "legacy_flat_map_unchanged": false, + "legacy_flat_map_scope": "active-key-only-after-2026-07-11-compromise", + "historical_key_source": "/.well-known/signing-key-registry.json", + "reason": "Lifecycle-unaware clients must fail closed for compromised keys; historical bytes remain available with explicit status." + }, + "verification_policy": { + "cryptographic_validity_separate_from_lifecycle": true, + "retired_keys_verify_historical_bytes": true, + "compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp": true, + "receipt_issued_at_alone_is_not_independent_time_evidence": true + }, + "limitations": [ + "This registry is an operational disclosure, not a standards-body or legal approval.", + "A self-signature by the active data key is not an independent lifecycle authority; pin this document or its keys outside this domain before using it as a policy gate.", + "The compromised_after boundary is deliberately conservative and has day-level incident precision.", + "registry_revision enables caller-enforced anti-rollback but is not self-enforcing; a live verifier must retain its minimum accepted revision through an authenticated channel. Historical snapshot verification is a separate explicit mode.", + "Dynamic Feed does not claim that every RFC 3161 or OpenTimestamps proof in the current implementation has received complete independent cryptographic validation." + ] +} diff --git a/examples/live-data-integrity/README.md b/examples/live-data-integrity/README.md index d5b5840..eb7e80e 100644 --- a/examples/live-data-integrity/README.md +++ b/examples/live-data-integrity/README.md @@ -1,35 +1,33 @@ # Live-Data Integrity Report A reproducible measurement anyone can re-run. For a set of live-data domains an AI agent might call, it -fetches the datapoint from Dynamic Feed, independently verifies its Ed25519 signature with the open-source -`dynamicfeed-verify` package (no trust in Dynamic Feed beyond the public key), and records three honest, -hard-to-game facts: +fetches the datapoint from Dynamic Feed, checks its Ed25519 signature and signer lifecycle against the +reviewed registry snapshot in this repository, and records separate integrity and evidence fields: -- **verifiable** — does the datapoint cryptographically verify against a published key? (yes/no) +- **policy accepted** — do signature mathematics and the pinned signer-lifecycle policy both pass? - **provenance** — does it name its source and observation time? - **freshness** — how old is the newest datapoint, from its own timestamp? -The point is structural, not a dig at any source: the same public data, fetched raw from the underlying -source an agent would otherwise call, carries no signature and no provenance. You cannot later prove what it -said, or when. Dynamic Feed returns it signed and provenance-stamped, and this script proves that by verifying -every datapoint itself. +The report checks the fields each returned object actually contains. It does not assume an upstream +source lacks provenance, and signer-policy acceptance does not establish objective truth. ## Reproduce it ```bash -pip install dynamicfeed-verify +python -m pip install -e ../../clients/python python report.py # prints the table + headline, writes results.json ``` Run it any day, against live sources, and reproduce the number yourself. Sample run: ``` -domain verifiable reliability freshness source +domain accepted reliability freshness source Weather (Sydney) YES MEDIUM 13m Open-Meteo Earthquakes YES MEDIUM 0m USGS Earthquake Hazards Program US Treasury yields YES MEDIUM 33.5h U.S. Department of the Treasury ... -6/6 verified against the published Ed25519 key with the open-source verifier. +6/6 passed signature mathematics and the pinned signer-lifecycle policy. ``` -Evidence and reproducible measurement, not a certification. Tamper-evident, not tamper-proof. +The checked-out registry is a reviewed out-of-band input; its current-domain counterpart is not an +independent trust root. Evidence and reproducible measurement, not truth, safety, or certification. diff --git a/examples/live-data-integrity/report.py b/examples/live-data-integrity/report.py index 7209a74..c85e3a7 100644 --- a/examples/live-data-integrity/report.py +++ b/examples/live-data-integrity/report.py @@ -2,32 +2,29 @@ """Live-Data Integrity Report — a reproducible measurement anyone can re-run. For a set of live-data domains an AI agent might call, this fetches the datapoint from Dynamic Feed, -independently VERIFIES its Ed25519 signature with the open-source `dynamicfeed-verify` package (no -trust in Dynamic Feed beyond fetching the public key), and records three honest, hard-to-game facts: +checks its Ed25519 signature and signer lifecycle with the reviewed out-of-band registry in this +repository, and records three separate facts: freshness — how old the newest datapoint is (from its own timestamp) provenance — does the datapoint name its source + observation time? - verifiable — does the datapoint cryptographically verify against a published key? (yes/no) + accepted — do signature mathematics and signer lifecycle policy both pass? (yes/no) -The point is structural, not a brag: the SAME public data, fetched raw from the underlying source an -agent would otherwise call, carries NO signature and NO provenance — you cannot later prove what it -said or when. Dynamic Feed returns it signed and provenance-stamped, and this script proves that by -verifying every datapoint itself. +The report checks only what each returned object actually contains. It does not assume an upstream +source lacks provenance, and signer-policy acceptance does not establish objective truth. - pip install dynamicfeed-verify + python -m pip install -e ../../clients/python python report.py # prints the table + a headline, writes results.json Re-run it any day, against live sources, and reproduce the number yourself. """ -import json, sys, time, urllib.request +import json, pathlib, urllib.request from datetime import datetime, timezone -try: - from dynamicfeed_verify import verify -except Exception: - print("pip install dynamicfeed-verify", file=sys.stderr); raise +from dynamicfeed_verify import _strict_loads, _urlopen_no_redirect, verify BASE = "https://dynamicfeed.ai" +REGISTRY_PATH = pathlib.Path(__file__).resolve().parents[2] / "SIGNING_KEY_LIFECYCLE.json" +REGISTRY = json.loads(REGISTRY_PATH.read_text()) # (label, DF tool + query, the public source an agent would otherwise call directly and unverifiably) DOMAINS = [ @@ -42,8 +39,9 @@ def fetch(path): url = f"{BASE}/v1/facts?tool={path}" if "?" not in path else f"{BASE}/v1/facts?tool={path.split('?')[0]}&{path.split('?',1)[1]}" - with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "df-integrity-report"}), timeout=25) as r: - return json.loads(r.read().decode()) + request = urllib.request.Request(url, headers={"User-Agent": "df-integrity-report"}) + with _urlopen_no_redirect(request, 25) as response: + return _strict_loads(response.read().decode("utf-8")) def _find(o, keys): if isinstance(o, dict): @@ -59,11 +57,16 @@ def _find(o, keys): return None def main(): - rows, verified = [], 0 + rows, accepted_count = [], 0 for label, path, source in DOMAINS: try: resp = fetch(path) - res = verify(resp) # independent Ed25519 check vs the published key + res = verify( + resp, + lifecycle_registry=REGISTRY, + highest_authenticated_registry_revision=REGISTRY["registry_revision"], + registry_source_authenticated=True, + ) ok = bool(res.get("ok")) facts = resp.get("facts") or [] newest = max(facts, key=lambda f: str(f.get("timestamp") or f.get("measured_at") or "")) if facts else {} @@ -78,27 +81,29 @@ def main(): except Exception: pass rows.append({"domain": label, "df_tool": path.split("?")[0], "public_source": source, - "verifiable": ok, "provenance": prov, "reliability": conf, + "policy_accepted": ok, "crypto_valid": res.get("crypto_valid"), + "signer_status": res.get("lifecycle_status"), + "provenance": prov, "reliability": conf, "freshness_min": age, "measured_at": measured}) - verified += 1 if ok else 0 + accepted_count += 1 if ok else 0 except Exception as e: - rows.append({"domain": label, "public_source": source, "verifiable": False, + rows.append({"domain": label, "public_source": source, "policy_accepted": False, "error": f"{type(e).__name__}: {e}"}) n = len(DOMAINS) out = {"report": "live-data-integrity/v1", "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "verified": verified, "total": n, "rows": rows, - "headline": f"{verified}/{n} live datapoints fetched from Dynamic Feed verified against its published " - f"Ed25519 key with the open-source verifier — each carrying its source and observation time. " - f"The same data fetched raw from the underlying public source carries no signature and no " - f"provenance: you cannot prove what it said, or when."} + "policy_accepted_count": accepted_count, "total": n, "rows": rows, + "registry_revision": REGISTRY["registry_revision"], + "headline": f"{accepted_count}/{n} live responses passed signature mathematics and the pinned signer-lifecycle " + f"policy. This records integrity and attribution under that policy, not objective truth or " + f"safety."} def _fresh(m): if m is None: return "-" return f"{m:.0f}m" if m < 90 else f"{m/60:.1f}h" print(f"\nLive-Data Integrity Report · {out['generated_at']}\n") - print(f"{'domain':22} {'verifiable':10} {'reliability':11} {'freshness':10} source") + print(f"{'domain':22} {'accepted':10} {'reliability':11} {'freshness':10} source") print("-" * 78) for r in rows: - print(f"{r['domain']:22} {('YES' if r.get('verifiable') else 'no'):10} " + print(f"{r['domain']:22} {('YES' if r.get('policy_accepted') else 'no'):10} " f"{(r.get('reliability') or '-'):11} {_fresh(r.get('freshness_min')):10} " f"{(r.get('provenance') or r.get('public_source') or '')}") print("\n" + out["headline"] + "\n") diff --git a/examples/live-data-integrity/results.json b/examples/live-data-integrity/results.json index 14ef1a7..6fbac8c 100644 --- a/examples/live-data-integrity/results.json +++ b/examples/live-data-integrity/results.json @@ -1,69 +1,8 @@ { "report": "live-data-integrity/v1", - "generated_at": "2026-07-01T09:27:44+00:00", - "verified": 6, - "total": 6, - "rows": [ - { - "domain": "Weather (Sydney)", - "df_tool": "current_weather", - "public_source": "Open-Meteo", - "verifiable": true, - "provenance": "Open-Meteo", - "reliability": "MEDIUM", - "freshness_min": 12.7, - "measured_at": "2026-07-01T09:15:00+00:00" - }, - { - "domain": "Earthquakes", - "df_tool": "earthquakes", - "public_source": "USGS", - "verifiable": true, - "provenance": "USGS Earthquake Hazards Program", - "reliability": "MEDIUM", - "freshness_min": 0.0, - "measured_at": "2026-07-01T09:27:41+00:00" - }, - { - "domain": "Tides (Boston)", - "df_tool": "tides", - "public_source": "NOAA CO-OPS", - "verifiable": true, - "provenance": "NOAA CO-OPS (Tides & Currents)", - "reliability": "MEDIUM", - "freshness_min": 0.0, - "measured_at": "2026-07-01T09:27:42.488594+00:00" - }, - { - "domain": "GitHub releases", - "df_tool": "github_releases", - "public_source": "GitHub API", - "verifiable": true, - "provenance": "GitHub Releases (atom feed)", - "reliability": "MEDIUM", - "freshness_min": 5.9, - "measured_at": "2026-07-01T09:21:50+00:00" - }, - { - "domain": "US Treasury yields", - "df_tool": "treasury_yields", - "public_source": "US Treasury", - "verifiable": true, - "provenance": "U.S. Department of the Treasury", - "reliability": "MEDIUM", - "freshness_min": 2007.7, - "measured_at": "2026-06-30T00:00:00+00:00" - }, - { - "domain": "Space weather", - "df_tool": "space_weather", - "public_source": "NOAA SWPC", - "verifiable": true, - "provenance": "NOAA Space Weather Prediction Center (SWPC)", - "reliability": "MEDIUM", - "freshness_min": 207.7, - "measured_at": "2026-07-01T06:00:00+00:00" - } - ], - "headline": "6/6 live datapoints fetched from Dynamic Feed verified against its published Ed25519 key with the open-source verifier \u2014 each carrying its source and observation time. The same data fetched raw from the underlying public source carries no signature and no provenance: you cannot prove what it said, or when." -} \ No newline at end of file + "status": "superseded_security_sample", + "original_generated_at": "2026-07-01T09:27:44+00:00", + "policy_accepted": null, + "reason": "The original sample used a lifecycle-unaware flat-key verifier and predated the 2026-07-11 compromise classification. It is not evidence of current signer-policy acceptance. Re-run report.py with the reviewed source and SIGNING_KEY_LIFECYCLE.json to produce a new result.", + "historical_signature_note": "Historical Ed25519 mathematics may remain inspectable, but no complete independent timestamp validation is asserted here." +} diff --git a/examples/openai/README.md b/examples/openai/README.md index 8478f46..1196152 100644 --- a/examples/openai/README.md +++ b/examples/openai/README.md @@ -1,17 +1,23 @@ # Grounding an OpenAI agent on verifiable live data -A runnable recipe: before an OpenAI agent acts on a live datapoint, it verifies the Ed25519 signature (integrity) and reads a portable reliability object (`confidence`, `verified`, `vantage`), then gates its action on that. The rule it teaches: **signed is not verified**. +A runnable recipe that separates Ed25519 mathematics, signer-lifecycle acceptance, and a portable +reliability object (`confidence`, `verified`, `vantage`). It produces advisory output; it is not an +actuator. The rule it teaches: **signed is not verified**. - [`grounding_on_verifiable_live_data.ipynb`](grounding_on_verifiable_live_data.ipynb) ## Run it ```bash -pip install openai cryptography requests +cd examples/openai +pip install openai requests -e ../../clients/python export OPENAI_API_KEY=sk-... export OPENAI_MODEL=gpt-4.1 # set to whichever current model you use ``` -The data source ([Dynamic Feed](https://dynamicfeed.ai)) is keyless, so the verification half runs with no account: fetch a fact, re-canonicalize it (`json-sorted-compact`), check the detached signature against the published key, and read the reliability object. The agent half uses the OpenAI Responses API with a function tool. The pattern is vendor-neutral; any source emitting the same object works. +The data source is keyless, but signer policy still needs trust material. The notebook uses the +reviewed [`SIGNING_KEY_LIFECYCLE.json`](../../SIGNING_KEY_LIFECYCLE.json) snapshot and a caller-held +revision floor. The agent half uses the OpenAI Responses API with a function tool. No signature or +model output should directly authorize a consequential action. The reliability object, its JSON Schema, and zero-dependency reference validators are in [`../../reliability`](../../reliability) (MIT). diff --git a/examples/openai/grounding_on_verifiable_live_data.ipynb b/examples/openai/grounding_on_verifiable_live_data.ipynb index 0af0b86..16178c7 100644 --- a/examples/openai/grounding_on_verifiable_live_data.ipynb +++ b/examples/openai/grounding_on_verifiable_live_data.ipynb @@ -10,7 +10,7 @@ "\n", "**The rule this recipe builds on: signed is not verified.**\n", "\n", - "This notebook shows a small, reusable pattern: before an OpenAI agent acts on a live datapoint, it (1) **verifies the Ed25519 signature** so it knows the bytes are intact and who produced them, and (2) reads a portable **reliability object** so it knows *how much to believe the value*, then gates its action on that. We use [Dynamic Feed](https://dynamicfeed.ai) as a keyless, signed live-data source because it emits this object on every datapoint, but the pattern is vendor-neutral: any source that returns the same shape works.\n", + "This notebook shows a small, reusable pattern: it (1) checks Ed25519 mathematics and signer lifecycle so it knows which selected key authenticated the bytes, and (2) reads a separate reliability object. This does not prove objective truth, physical origin, or that a model used the record. The example produces advisory output and never invokes an actuator.\n", "\n", "The reliability object carries three separable axes:\n", "\n", @@ -35,14 +35,15 @@ "execution_count": null, "outputs": [], "source": [ - "%pip install --quiet openai cryptography requests\n", + "%pip install --quiet openai requests -e ../../clients/python\n", "\n", - "import os, json, base64, requests\n", - "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey\n", + "import os, json, pathlib, requests\n", + "from dynamicfeed_verify import verify\n", "\n", "# os.environ['OPENAI_API_KEY'] = 'sk-...' # set your key\n", "MODEL = os.environ.get('OPENAI_MODEL', 'gpt-4.1') # set to whichever current model you use\n", - "DF = 'https://dynamicfeed.ai'" + "DF = 'https://dynamicfeed.ai'\n", + "REGISTRY = json.loads(pathlib.Path('../../SIGNING_KEY_LIFECYCLE.json').read_text())" ] }, { @@ -51,7 +52,7 @@ "source": [ "## 1. The verifier: fetch, check the signature, read reliability\n", "\n", - "This is the whole trust step, with no dependency on the provider at runtime beyond fetching its public key. It re-canonicalizes the response exactly as it was signed (`json-sorted-compact`), checks the detached Ed25519 signature against the published key, and returns the value alongside its reliability object. Change one byte and verification fails." + "This uses the reviewed out-of-band lifecycle registry from the repository. It separately checks signature mathematics and whether the signer is accepted by live policy. The current-domain registry is not an independent trust root, and a flat public key cannot establish signer lifecycle." ] }, { @@ -60,31 +61,21 @@ "execution_count": null, "outputs": [], "source": [ - "def _b64u(s: str) -> bytes:\n", - " return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))\n", - "\n", "def fetch_verified(tool: str, **args) -> dict:\n", - " \"\"\"Fetch a live datapoint, verify its Ed25519 signature, and return {value, reliability, integrity}.\"\"\"\n", + " \"\"\"Fetch a datapoint and return separate crypto, signer-policy, and reliability state.\"\"\"\n", " doc = requests.get(f'{DF}/v1/facts', params={'tool': tool, **args}, timeout=20).json()\n", - " sig = doc.get('signature') or {}\n", - "\n", - " # 1) integrity: re-canonicalize the payload (signature field stripped) and verify the detached signature\n", - " payload = {k: v for k, v in doc.items() if k != 'signature'}\n", - " canon = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode()\n", - " keys = requests.get(f'{DF}/.well-known/keys', timeout=20).json()\n", - " pub = keys.get(sig.get('key_id'))\n", - " integrity_ok = False\n", - " try:\n", - " Ed25519PublicKey.from_public_bytes(_b64u(pub)).verify(_b64u(sig['sig']), canon)\n", - " integrity_ok = True\n", - " except Exception:\n", - " integrity_ok = False\n", + "\n", + " checked = verify(doc, lifecycle_registry=REGISTRY,\n", + " highest_authenticated_registry_revision=REGISTRY['registry_revision'],\n", + " registry_source_authenticated=True)\n", "\n", " fact = (doc.get('facts') or [{}])[0]\n", " return {\n", " 'value': fact.get('value'),\n", " 'reliability': fact.get('reliability') or {},\n", - " 'integrity_ok': integrity_ok, # signature valid: bytes intact\n", + " 'integrity_ok': checked.get('crypto_valid') is True,\n", + " 'signer_policy_ok': checked.get('ok') is True,\n", + " 'signer_status': checked.get('lifecycle_status'),\n", " 'entity': fact.get('entity'),\n", " }" ] @@ -107,6 +98,8 @@ " \"\"\"Decide whether an agent should act on a verified fetch result.\"\"\"\n", " if not r['integrity_ok']:\n", " return 'REFUSE: signature did not verify (data may be altered)'\n", + " if not r['signer_policy_ok']:\n", + " return f\"REFUSE: signer lifecycle rejected ({r['signer_status']})\"\n", " rel = r['reliability']\n", " if require_independent and rel.get('vantage') != 'independent':\n", " return 'ABSTAIN: producer-reported; an independent vantage was required'\n", @@ -116,7 +109,7 @@ "\n", "r = fetch_verified('current_weather', city='Tokyo')\n", "print('value :', r['value'].get('temperature_c'), 'C' if isinstance(r['value'], dict) else r['value'])\n", - "print('integrity :', r['integrity_ok'])\n", + "print('integrity :', r['integrity_ok'], 'signer policy:', r['signer_policy_ok'])\n", "print('reliability:', {k: r['reliability'].get(k) for k in ('confidence','verified','vantage','sources','basis')})\n", "print('decision :', gate(r))" ] @@ -156,9 +149,9 @@ "\n", "SYSTEM = (\n", " 'You are an agent that acts on live data. Before relying on a value, call get_verified_fact. '\n", - " 'It returns integrity_ok (signature valid) and a reliability object (confidence, verified, vantage). '\n", + " 'It returns integrity_ok, signer_policy_ok, and a reliability object (confidence, verified, vantage). '\n", " 'Rule: signed is not verified. State the confidence and vantage. If verified is not true, say so and '\n", - " 'treat the value as provisional rather than fact. Refuse to rely on it if integrity_ok is false.'\n", + " 'treat the value as provisional rather than fact. Refuse to rely on it unless both integrity_ok and signer_policy_ok are true.'\n", ")" ] }, @@ -180,7 +173,7 @@ " args = json.loads(c.arguments)\n", " result = fetch_verified(**args)\n", " msgs.append({'type': 'function_call_output', 'call_id': c.call_id,\n", - " 'output': json.dumps({k: result[k] for k in ('value','reliability','integrity_ok')})})\n", + " 'output': json.dumps({k: result[k] for k in ('value','reliability','integrity_ok','signer_policy_ok','signer_status')})})\n", " final = client.responses.create(model=MODEL, input=msgs, tools=tools)\n", " return final.output_text\n", "\n", @@ -215,4 +208,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/examples/verified-agent/README.md b/examples/verified-agent/README.md index 1f9d8ba..2d3d626 100644 --- a/examples/verified-agent/README.md +++ b/examples/verified-agent/README.md @@ -1,57 +1,54 @@ -# verified-agent: an AI agent that verifies before it acts +# verified-agent — verify advisory evidence before policy review -A ~90-line, runnable example of the **DF-VERIFY/1** pattern: *an autonomous agent must not act on world-state it cannot cryptographically verify.* +This runnable example fetches a live `awareness/v1` advisory and checks four separate properties: -The agent asks [Dynamic Feed](https://dynamicfeed.ai) for a signed go / caution / no-go verdict about a location, verifies the **Ed25519 signature** against the issuer's published key, and only proceeds if the data is **authentic, unaltered, and permits the action**. Tamper with the verdict after it was signed and the agent refuses to act. +1. the signature declares exactly `Ed25519` and `json-sorted-compact`; +2. the public key derives the declared `signature.key_id`; +3. the Ed25519 signature matches the canonical record bytes; and +4. the signer is accepted by a validated `df-signing-key-registry/v1` lifecycle policy. -## Run it (< 5 min) +It deliberately does **not** actuate. A valid signature proves integrity under a selected key, not objective truth, safety, authorization, or that a machine used the record. A passing check only makes the advisory eligible for caller-owned policy, independent safety checks, and authorization. + +## Run it ```bash pip install cryptography -python agent.py # fetch a live signed verdict, verify it, then act -python agent.py --tamper # alter the verdict after signing → verification fails → agent refuses +python agent.py +python agent.py --tamper ``` -Expected: +Expected shape: -``` -$ python agent.py -✅ VERIFIED (signed by df-ed25519-4cb32e72f333) · verdict='caution', proceeding with the action. +```text +ACCEPTED AS ADVISORY EVIDENCE (key df-ed25519-, lifecycle active) · verdict='caution' +NO ACTION EXECUTED — caller-owned policy, independent safety checks, and authorization remain required. -$ python agent.py --tamper -⚠ tamper mode: flipped the verdict to 'go' after it was signed -⛔ REFUSING TO ACT. Unverifiable world-state: signature invalid: ... - An agent must never act on data it cannot prove is authentic and unaltered. +tamper mode: rewrote the verdict after signing +REJECTED — advisory record not accepted: signature invalid ``` -## Why this matters - -When an AI *acts* (it moves a robot, places a trade, files a claim, dispatches a crew), the data it acted on becomes a liability question: *can you prove what the agent was told, and that no one altered it?* DF-VERIFY answers that with a portable signature anyone can check, with no account and no trust in the vendor. You can verify even against us. +## What the verifier does -The whole verifier is the ~12 lines under `# DF-VERIFY/1` in `agent.py`. Three steps: +The example strictly parses downloaded JSON, rejecting duplicate keys, invalid constants, malformed input, and oversized responses. It then: -1. Drop the `signature` block; keep the rest as the payload. -2. Canonicalize: JSON, keys sorted recursively, compact separators, UTF-8. -3. Fetch the public key from `/.well-known/keys`, look up `signature.key_id`, verify the Ed25519 signature over the canonical bytes. One changed byte → it fails. +1. removes the top-level `signature` block; +2. canonicalizes the remaining record with recursively sorted keys, compact separators, ASCII escaping, and UTF-8; +3. validates the lifecycle registry and its public-key fingerprints; +4. derives `key_id` from the selected public key; +5. verifies the detached Ed25519 signature; and +6. rejects compromised keys. A fresh real-time advisory must use the registry's active key. -## In production +Two historical anchor conventions exist. If a legacy receipt appended `anchor` after signing, the signature can still validate but the result reports `anchor_authenticated: false`. That anchor must not be treated as covered by the envelope signature. -This file inlines the verifier so it runs with zero install beyond `cryptography`. For real use, install the packaged reference verifier: +The default registry is fetched from [`/.well-known/signing-key-registry.json`](https://dynamicfeed.ai/.well-known/signing-key-registry.json). Because it comes from the same current HTTPS domain as the record, it is operational disclosure—not an independent trust root. High-assurance consumers should pin the registry or its expected key identities through an out-of-band channel and explicitly record whether that source was authenticated. Merely passing a caller-supplied object does not make it independent. The flat [`/.well-known/keys`](https://dynamicfeed.ai/.well-known/keys) map contains cryptographic bytes only and cannot produce an overall accepted lifecycle result. -```bash -pip install dynamicfeed-verify -``` +Live mode requires the active key and `registry_revision >= 1`. A caller may raise that floor or retain the highest authenticated revision durably; rollback protection is reported only when both authenticated source state and a retained revision are supplied. `historical_snapshot` mode is explicit and non-actionable, and cannot make the live `ok` result pass. -```python -from dynamicfeed_verify import verify_live -env, result = verify_live() -if not result["ok"]: - raise RuntimeError(f"unverified world-state: {result['error']}") -``` +For production, use a lifecycle-aware verifier release that exposes separate cryptographic, key-binding, lifecycle, and anchor-scope results. Do not assume an older package release is lifecycle-aware merely because it accepts the signature mathematics. -- **Spec:** https://dynamicfeed.ai/standard -- **Verify in the browser:** https://dynamicfeed.ai/proof -- **Public keys (a key_id to Ed25519 public-key map):** https://dynamicfeed.ai/.well-known/keys +- **Profile:** https://dynamicfeed.ai/standard +- **Browser verifier:** https://dynamicfeed.ai/verify.js +- **Lifecycle registry:** https://dynamicfeed.ai/.well-known/signing-key-registry.json ## License diff --git a/examples/verified-agent/agent.py b/examples/verified-agent/agent.py index af30f6f..e074df9 100644 --- a/examples/verified-agent/agent.py +++ b/examples/verified-agent/agent.py @@ -1,97 +1,348 @@ #!/usr/bin/env python3 -""" -verified-agent: an example AI agent that VERIFIES the world before it acts. - -The DF-VERIFY/1 pattern: an autonomous agent must not act on world-state it cannot cryptographically -verify. This fetches a signed go / caution / no-go verdict from Dynamic Feed, verifies the Ed25519 -signature against the issuer's published key, and acts ONLY if the data is authentic, unaltered, and -the verdict permits it. Tamper with the data after it was signed and the agent refuses to act. +"""Verify a live Dynamic Feed advisory without actuating from verification alone. -Run: - pip install cryptography - python agent.py # verify a live verdict, then act - python agent.py --tamper # alter the data after signing -> verification fails -> agent refuses - -Self-contained: the whole verifier is the ~12 lines under "DF-VERIFY/1" below. For production, use the -packaged reference verifier instead: pip install dynamicfeed-verify -Spec: https://dynamicfeed.ai/standard +The example checks exact signature metadata, key-id binding, Ed25519 signature +mathematics, and current signing-key lifecycle. The default lifecycle registry +comes from the same HTTPS domain, so it is explicitly *not* an independent trust +root. A successful result is evidence for caller-owned policy and safety logic; +this program never invokes an actuator. """ from __future__ import annotations import argparse import base64 +import hashlib import json import urllib.request +from datetime import datetime, timezone +from typing import Optional from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey BASE = "https://dynamicfeed.ai" +MAX_JSON_BYTES = 2 * 1024 * 1024 +COMPILED_MIN_REGISTRY_REVISION = 1 + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Reject redirects so an authenticated registry origin cannot be swapped.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError(f"redirect rejected while fetching trust material ({code})") + + +_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect) + + +def _b64(value: str) -> bytes: + if not isinstance(value, str): + raise ValueError("base64url value must be text") + padded = value + "=" * (-len(value) % 4) + return base64.b64decode(padded, altchars=b"-_", validate=True) + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON object key: {key}") + result[key] = value + return result + +def _strict_loads(raw: str): + return json.loads( + raw, + object_pairs_hook=_unique_object, + parse_constant=lambda value: (_ for _ in ()).throw( + ValueError(f"non-standard JSON constant: {value}") + ), + ) -# ---- DF-VERIFY/1 verification (this is the entire thing) --------------------- -def _b64(s: str) -> bytes: - return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + +def _fetch_json(url: str, *, data: Optional[bytes] = None, timeout: float = 25): + headers = {"Cache-Control": "no-cache", "User-Agent": "dynamicfeed-verified-agent/1"} + if data is not None: + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers) + with _NO_REDIRECT_OPENER.open(request, timeout=timeout) as response: + raw = response.read(MAX_JSON_BYTES + 1) + if len(raw) > MAX_JSON_BYTES: + raise ValueError("JSON response exceeds size limit") + return _strict_loads(raw.decode("utf-8")) + + +def _utc(value, field): + if not isinstance(value, str) or not value.endswith("Z"): + raise ValueError(f"{field} must be RFC3339 UTC") + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + if parsed.tzinfo != timezone.utc: + raise ValueError(f"{field} must use UTC") + return parsed + + +def _validate_registry(registry: dict, minimum_revision: int = COMPILED_MIN_REGISTRY_REVISION) -> dict: + if not isinstance(registry, dict) or registry.get("schema") != "df-signing-key-registry/v1" \ + or registry.get("issuer") != "dynamicfeed.ai": + raise ValueError("invalid signing-key registry identity") + if isinstance(minimum_revision, bool) or not isinstance(minimum_revision, int) \ + or minimum_revision < COMPILED_MIN_REGISTRY_REVISION: + raise ValueError("minimum registry revision must be a positive integer") + revision = registry.get("registry_revision") + if isinstance(revision, bool) or not isinstance(revision, int) or revision < minimum_revision: + raise ValueError(f"registry revision is missing or below minimum {minimum_revision}") + rollback = registry.get("rollback_protection") or {} + if rollback.get("live_policy_minimum_revision") != revision \ + or rollback.get("live_policy_requires_authenticated_update") is not True \ + or rollback.get("historical_snapshot_mode_must_be_explicit") is not True \ + or rollback.get("self_asserted_revision_is_not_anti_rollback") is not True: + raise ValueError("registry rollback-protection disclosure is incomplete") + _utc(registry.get("updated_at"), "updated_at") + keys = registry.get("public_keys") + lifecycle = registry.get("lifecycle") + active_id = registry.get("active_key_id") + if not isinstance(keys, dict) or not keys or not isinstance(lifecycle, dict) \ + or set(keys) != set(lifecycle) or active_id not in keys: + raise ValueError("incomplete signing-key registry") + active = [] + for key_id, encoded in keys.items(): + public_raw = _b64(encoded) + fingerprint = hashlib.sha256(public_raw).hexdigest() + if len(public_raw) != 32 or key_id != "df-ed25519-" + fingerprint[:12]: + raise ValueError(f"public key does not derive {key_id}") + metadata = lifecycle[key_id] + if not isinstance(metadata, dict) or metadata.get("alg") != "Ed25519" \ + or metadata.get("use") != "receipt-signing" \ + or metadata.get("fingerprint_sha256") != fingerprint \ + or metadata.get("public_key_retained") is not True \ + or metadata.get("status") not in {"active", "retired", "compromised"}: + raise ValueError(f"invalid lifecycle entry for {key_id}") + first_used = _utc(metadata.get("first_used_at"), f"{key_id}.first_used_at") + active_from = _utc(metadata.get("active_from"), f"{key_id}.active_from") + if first_used > active_from: + raise ValueError(f"invalid first-use interval for {key_id}") + if metadata["status"] == "active": + active.append(key_id) + if metadata.get("retired_at") is not None or metadata.get("compromised_after") is not None: + raise ValueError(f"active key {key_id} carries retirement metadata") + else: + retired_at = _utc(metadata.get("retired_at"), f"{key_id}.retired_at") + if active_from > retired_at: + raise ValueError(f"invalid retirement interval for {key_id}") + if metadata["status"] == "compromised": + if _utc(metadata.get("compromised_after"), f"{key_id}.compromised_after") > retired_at: + raise ValueError(f"invalid compromise interval for {key_id}") + elif metadata.get("compromised_after") is not None: + raise ValueError(f"retired key {key_id} carries compromise metadata") + if active != [active_id]: + raise ValueError("active-key policy is inconsistent") + if (registry.get("authority") or {}).get("independent_registry_root") is not False: + raise ValueError("registry authority classification is missing") + policy = registry.get("verification_policy") or {} + required = ( + "cryptographic_validity_separate_from_lifecycle", + "retired_keys_verify_historical_bytes", + "compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp", + "receipt_issued_at_alone_is_not_independent_time_evidence", + ) + if any(policy.get(field) is not True for field in required): + raise ValueError("registry verification policy is incomplete") + return registry def canonical(payload: dict) -> bytes: return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") -def verify(env: dict, base: str = BASE, jwks: dict | None = None) -> dict: - sig = (env or {}).get("signature") or {} - if not sig.get("key_id") or not sig.get("sig"): - return {"ok": False, "error": "no signature block"} - payload = {k: v for k, v in env.items() if k != "signature"} - if jwks is None: - with urllib.request.urlopen(base.rstrip("/") + "/.well-known/keys", timeout=20) as r: - jwks = json.load(r) - if sig["key_id"] not in jwks: - return {"ok": False, "error": "signing key not published (rotated or ephemeral)"} +def verify(env: dict, base: str = BASE, lifecycle_registry: Optional[dict] = None, *, + mode: str = "live", minimum_registry_revision: int = COMPILED_MIN_REGISTRY_REVISION, + retained_registry_revision: Optional[int] = None, + registry_source_authenticated: bool = False) -> dict: + """Return separate cryptographic, key-binding and lifecycle results.""" + if mode not in {"live", "historical_snapshot"}: + return {"ok": False, "crypto_valid": False, "mode": mode, + "error": "mode must be live or historical_snapshot"} + if not isinstance(env, dict): + return {"ok": False, "crypto_valid": False, + "error": "expected a top-level JSON object"} + floors = [COMPILED_MIN_REGISTRY_REVISION, minimum_registry_revision] + if retained_registry_revision is not None: + floors.append(retained_registry_revision) + if any(isinstance(value, bool) or not isinstance(value, int) or value < 1 for value in floors): + return {"ok": False, "crypto_valid": False, "mode": mode, + "error": "registry revision floors must be positive integers"} + live_revision_floor = max(floors) + registry_validation_floor = (COMPILED_MIN_REGISTRY_REVISION + if mode == "historical_snapshot" else live_revision_floor) + signature = env.get("signature") + if not isinstance(signature, dict): + return {"ok": False, "crypto_valid": False, "error": "no signature object"} + key_id = signature.get("key_id") + signature_text = signature.get("sig") + if signature.get("alg") != "Ed25519" \ + or signature.get("canonicalization") != "json-sorted-compact": + return {"ok": False, "crypto_valid": False, "key_id": key_id, + "error": "unsupported signature metadata"} + if not isinstance(key_id, str) or not isinstance(signature_text, str): + return {"ok": False, "crypto_valid": False, "error": "signature missing key_id or sig"} + + official_origin = base.rstrip("/") == BASE + if lifecycle_registry is not None: + registry_source = "out-of-band" + source_authenticated = bool(registry_source_authenticated) + trust_basis = ("caller-authenticated-out-of-band" if source_authenticated + else "caller-supplied-unauthenticated") + else: + registry_source = ("current-domain-https" if official_origin + else "caller-configured-network-origin") + source_authenticated = official_origin + trust_basis = ("current-domain-https" if official_origin + else "unauthenticated-custom-origin") try: - Ed25519PublicKey.from_public_bytes(_b64(jwks[sig["key_id"]])).verify(_b64(sig["sig"]), canonical(payload)) - except Exception as e: # any failure => not verified - return {"ok": False, "error": f"signature invalid: {e}"} - return {"ok": True, "key_id": sig["key_id"], "verdict": (env.get("verdict") or {}).get("status")} -# ------------------------------------------------------------------------------ + registry = _validate_registry(lifecycle_registry, registry_validation_floor) if lifecycle_registry is not None else \ + _validate_registry(_fetch_json( + base.rstrip("/") + "/.well-known/signing-key-registry.json", timeout=20 + ), registry_validation_floor) + except Exception as exc: # fail closed on registry fetch, parse, or policy errors + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "trust_basis": trust_basis, + "registry_source": registry_source, + "registry_source_authenticated": source_authenticated, + "independent_trust_root": False, + "error": f"lifecycle registry unavailable or invalid: {exc}"} + encoded_key = registry["public_keys"].get(key_id) + if not encoded_key: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": key_id, + "trust_basis": trust_basis, + "registry_source": registry_source, + "registry_source_authenticated": source_authenticated, + "independent_trust_root": False, + "error": "key absent from lifecycle registry"} + try: + public_raw = _b64(encoded_key) + signature_raw = _b64(signature_text) + except Exception as exc: + return {"ok": False, "crypto_valid": False, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": key_id, + "error": f"invalid signature or public-key encoding: {exc}"} + derived_id = "df-ed25519-" + hashlib.sha256(public_raw).hexdigest()[:12] + key_binding_valid = len(public_raw) == 32 and derived_id == key_id + if not key_binding_valid or len(signature_raw) != 64: + return {"ok": False, "crypto_valid": False, + "key_binding_valid": key_binding_valid, "signer_accepted": False, + "lifecycle_status": "unknown", "key_id": key_id, + "error": "signature length or key-id binding is invalid"} + + stripped = {key: value for key, value in env.items() if key != "signature"} + candidates = [(stripped, True if "anchor" in stripped else None)] + if "anchor" in stripped: + candidates.append(({key: value for key, value in stripped.items() if key != "anchor"}, False)) + crypto_valid = False + anchor_authenticated = None + public_key = Ed25519PublicKey.from_public_bytes(public_raw) + for payload, anchor_scope in candidates: + try: + public_key.verify(signature_raw, canonical(payload)) + crypto_valid = True + anchor_authenticated = anchor_scope + break + except Exception: + continue + + metadata = registry["lifecycle"][key_id] + status = metadata["status"] + live_signer_accepted = (source_authenticated and status == "active" + and registry["active_key_id"] == key_id) + historical_accepted = crypto_valid and key_binding_valid and status in {"active", "retired"} + signer_accepted = live_signer_accepted if mode == "live" else False + if status == "compromised": + lifecycle_detail = ("compromised key; no independently verified signature-inclusive " + "pre-boundary commitment") + elif status == "retired": + lifecycle_detail = "retired key accepted for historical byte verification only" + elif not source_authenticated: + lifecycle_detail = "lifecycle registry source was not authenticated" + else: + lifecycle_detail = "active lifecycle key" if signer_accepted else "active key mismatch" + ok = mode == "live" and crypto_valid and key_binding_valid and signer_accepted + verdict = env.get("verdict") + return { + "ok": ok, + "crypto_valid": crypto_valid, + "key_binding_valid": key_binding_valid, + "signer_accepted": signer_accepted, + "lifecycle_status": status, + "mode": mode, + "live_accepted": ok, + "historical_accepted": historical_accepted, + "trust_basis": trust_basis, + "registry_source": registry_source, + "registry_source_authenticated": source_authenticated, + "independent_trust_root": False, + "registry_revision": registry["registry_revision"], + "minimum_registry_revision": (0 if mode == "historical_snapshot" else live_revision_floor), + "revision_source": ("historical-snapshot-no-live-floor" + if mode == "historical_snapshot" else + ("durable-retained-floor" if retained_registry_revision is not None + else "compiled/caller-floor")), + "rollback_protected": bool(mode == "live" and source_authenticated + and retained_registry_revision is not None + and registry["registry_revision"] >= retained_registry_revision), + "action_authorized": False, + "trust_note": ("the current-domain registry is operational disclosure, not an independent " + "trust root; pin it out of band for independent identity"), + "anchor_authenticated": anchor_authenticated, + "key_id": key_id, + "verdict": verdict.get("status") if isinstance(verdict, dict) else verdict, + "error": None if ok else ("signature invalid" if not crypto_valid else ( + "historical snapshot mode is non-actionable" + if mode == "historical_snapshot" else lifecycle_detail + )), + } def fetch_verdict(base: str = BASE, lat: float = 51.5, lon: float = -0.12) -> dict: body = {"robot": {"class": "aerial"}, "location": {"lat": lat, "lon": lon}} - req = urllib.request.Request(base.rstrip("/") + "/v1/awareness", - data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=25) as r: - return json.load(r) - - -def agent_step(env: dict, base: str = BASE, jwks: dict | None = None) -> bool: - """Verify the world-state, THEN decide whether to act. The whole point: never act on unverified data.""" - res = verify(env, base, jwks) - if not res["ok"]: - print(f"⛔ REFUSING TO ACT. Unverifiable world-state: {res['error']}") - print(" An agent must never act on data it cannot prove is authentic and unaltered.") + return _fetch_json( + base.rstrip("/") + "/v1/awareness", + data=json.dumps(body, separators=(",", ":")).encode("utf-8"), + ) + + +def agent_step(env: dict, base: str = BASE) -> bool: + """Check advisory evidence and stop before actuation.""" + result = verify(env, base) + if not result["ok"]: + print(f"REJECTED — advisory record not accepted: {result['error']}") + return False + if result["lifecycle_status"] != "active": + print("REJECTED — a fresh real-time advisory must use the active lifecycle key") return False - if res["verdict"] == "no-go": - print(f"🛑 STAND DOWN: verified NO-GO verdict (signed by {res['key_id']}).") + if result["verdict"] == "no-go": + print(f"STAND DOWN — signed advisory says no-go (key {result['key_id']}).") return False - print(f"✅ VERIFIED (signed by {res['key_id']}) · verdict={res['verdict']!r}, proceeding with the action.") - # >>> the agent's real action goes here: move, trade, file, dispatch, ... <<< + print( + f"ACCEPTED AS ADVISORY EVIDENCE (key {result['key_id']}, lifecycle active) · " + f"verdict={result['verdict']!r}" + ) + print("NO ACTION EXECUTED — caller-owned policy, independent safety checks, and authorization remain required.") return True def main() -> None: - ap = argparse.ArgumentParser(description="An AI agent that verifies before it acts (DF-VERIFY/1).") - ap.add_argument("--tamper", action="store_true", - help="alter the verdict AFTER it was signed, to prove the agent refuses tampered data") - ap.add_argument("--base", default=BASE) - a = ap.parse_args() - - env = fetch_verdict(a.base) - if a.tamper: - v = env.get("verdict") - if isinstance(v, dict): - v["status"] = "no-go" if v.get("status") == "go" else "go" # attacker rewrites the signed verdict - print("⚠ tamper mode: rewrote the verdict after it was signed\n") - raise SystemExit(0 if agent_step(env, a.base) else 1) + parser = argparse.ArgumentParser(description="Verify a live advisory without actuating from it.") + parser.add_argument("--tamper", action="store_true", + help="alter the verdict after signing to demonstrate rejection") + parser.add_argument("--base", default=BASE) + args = parser.parse_args() + envelope = fetch_verdict(args.base) + if args.tamper: + verdict = envelope.get("verdict") + if isinstance(verdict, dict): + verdict["status"] = "no-go" if verdict.get("status") == "go" else "go" + print("tamper mode: rewrote the verdict after signing") + raise SystemExit(0 if agent_step(envelope, args.base) else 1) if __name__ == "__main__": diff --git a/examples/verified-agent/test_agent.py b/examples/verified-agent/test_agent.py new file mode 100644 index 0000000..40e567b --- /dev/null +++ b/examples/verified-agent/test_agent.py @@ -0,0 +1,198 @@ +import base64 +import hashlib +import importlib.util +import io +import json +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +SPEC = importlib.util.spec_from_file_location("verified_agent", Path(__file__).with_name("agent.py")) +agent = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(agent) + + +def identity(private_key): + raw = private_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + fingerprint = hashlib.sha256(raw).hexdigest() + return raw, fingerprint, "df-ed25519-" + fingerprint[:12] + + +def metadata(fingerprint, status): + active = status == "active" + return { + "alg": "Ed25519", "use": "receipt-signing", "fingerprint_sha256": fingerprint, + "status": status, "first_used_at": "2026-07-01T00:00:00Z", + "active_from": "2026-07-01T00:00:00Z", + "retired_at": None if active else "2026-07-03T00:00:00Z", + "compromised_after": None if active else "2026-07-02T00:00:00Z", + "public_key_retained": True, + } + + +class VerifiedAgentTests(unittest.TestCase): + def setUp(self): + self.old_private = Ed25519PrivateKey.generate() + self.active_private = Ed25519PrivateKey.generate() + old_raw, old_fingerprint, self.old_id = identity(self.old_private) + active_raw, active_fingerprint, self.active_id = identity(self.active_private) + self.registry = { + "schema": "df-signing-key-registry/v1", "issuer": "dynamicfeed.ai", + "registry_revision": 1, + "updated_at": "2026-07-11T00:00:00Z", "active_key_id": self.active_id, + "public_keys": { + self.old_id: base64.urlsafe_b64encode(old_raw).decode(), + self.active_id: base64.urlsafe_b64encode(active_raw).decode(), + }, + "lifecycle": { + self.old_id: metadata(old_fingerprint, "compromised"), + self.active_id: metadata(active_fingerprint, "active"), + }, + "authority": {"independent_registry_root": False}, + "verification_policy": { + "cryptographic_validity_separate_from_lifecycle": True, + "retired_keys_verify_historical_bytes": True, + "compromised_key_pre_boundary_evidence_requires_independently_validated_timestamp": True, + "receipt_issued_at_alone_is_not_independent_time_evidence": True, + }, + "rollback_protection": { + "live_policy_minimum_revision": 1, + "live_policy_requires_authenticated_update": True, + "historical_snapshot_mode_must_be_explicit": True, + "self_asserted_revision_is_not_anti_rollback": True, + }, + } + + def envelope(self, private_key, key_id, *, anchor=None): + body = {"schema": "awareness/v1", "verdict": {"status": "caution"}} + signature = private_key.sign(agent.canonical(body)) + envelope = dict(body) + if anchor is not None: + envelope["anchor"] = anchor + envelope["signature"] = { + "alg": "Ed25519", "key_id": key_id, + "canonicalization": "json-sorted-compact", + "sig": base64.urlsafe_b64encode(signature).decode().rstrip("="), + } + return envelope + + def test_active_passes_and_compromised_rejects(self): + active = agent.verify( + self.envelope(self.active_private, self.active_id), + lifecycle_registry=self.registry, + registry_source_authenticated=True, + ) + self.assertTrue(active["ok"]) + compromised = agent.verify( + self.envelope(self.old_private, self.old_id), + lifecycle_registry=self.registry, + registry_source_authenticated=True, + ) + self.assertTrue(compromised["crypto_valid"]) + self.assertFalse(compromised["ok"]) + self.assertEqual(compromised["lifecycle_status"], "compromised") + + def test_legacy_anchor_scope_is_reported(self): + result = agent.verify( + self.envelope( + self.active_private, self.active_id, + anchor={"status": "imprint_matched_unverified"}, + ), + lifecycle_registry=self.registry, + registry_source_authenticated=True, + ) + self.assertTrue(result["ok"]) + self.assertFalse(result["anchor_authenticated"]) + + def test_strict_json_and_metadata(self): + self.assertFalse(agent.verify([])["ok"]) + with self.assertRaisesRegex(ValueError, "duplicate"): + agent._strict_loads('{"a":1,"a":2}') + envelope = self.envelope(self.active_private, self.active_id) + envelope["signature"]["alg"] = "ed25519" + self.assertFalse(agent.verify( + envelope, lifecycle_registry=self.registry, registry_source_authenticated=True, + )["ok"]) + + def test_registry_revision_is_positive_and_meets_floor(self): + for revision in (None, 0, True): + bad = json.loads(json.dumps(self.registry)) + if revision is None: + bad.pop("registry_revision") + else: + bad["registry_revision"] = revision + self.assertFalse(agent.verify( + self.envelope(self.active_private, self.active_id), + lifecycle_registry=bad, + registry_source_authenticated=True, + )["ok"]) + self.assertFalse(agent.verify( + self.envelope(self.active_private, self.active_id), + lifecycle_registry=self.registry, + minimum_registry_revision=2, + registry_source_authenticated=True, + )["ok"]) + + def test_custom_origin_does_not_self_authenticate(self): + envelope = self.envelope(self.active_private, self.active_id) + with mock.patch.object(agent, "_fetch_json", return_value=self.registry): + custom = agent.verify(envelope, base="https://attacker.invalid") + self.assertTrue(custom["crypto_valid"]) + self.assertFalse(custom["ok"]) + self.assertEqual(custom["registry_source"], "caller-configured-network-origin") + self.assertFalse(custom["registry_source_authenticated"]) + + def test_success_does_not_actuate(self): + accepted = { + "ok": True, "lifecycle_status": "active", "verdict": "caution", + "key_id": self.active_id, + } + output = io.StringIO() + with mock.patch.object(agent, "verify", return_value=accepted), redirect_stdout(output): + self.assertTrue(agent.agent_step({})) + self.assertIn("NO ACTION EXECUTED", output.getvalue()) + + def test_registry_source_and_historical_mode_are_explicit(self): + envelope = self.envelope(self.active_private, self.active_id) + unauthenticated = agent.verify( + envelope, + lifecycle_registry=self.registry, + retained_registry_revision=1, + ) + self.assertFalse(unauthenticated["ok"]) + self.assertFalse(unauthenticated["registry_source_authenticated"]) + self.assertFalse(unauthenticated["independent_trust_root"]) + self.assertFalse(unauthenticated["rollback_protected"]) + + authenticated = agent.verify( + envelope, + lifecycle_registry=self.registry, + retained_registry_revision=1, + registry_source_authenticated=True, + ) + self.assertTrue(authenticated["rollback_protected"]) + historical = agent.verify( + envelope, + lifecycle_registry=self.registry, + mode="historical_snapshot", + minimum_registry_revision=2, + retained_registry_revision=2, + registry_source_authenticated=True, + ) + self.assertTrue(historical["crypto_valid"]) + self.assertTrue(historical["historical_accepted"]) + self.assertFalse(historical["ok"]) + self.assertFalse(historical["action_authorized"]) + self.assertEqual(historical["minimum_registry_revision"], 0) + self.assertEqual(historical["revision_source"], "historical-snapshot-no-live-floor") + + +if __name__ == "__main__": + unittest.main() diff --git a/reliability/a2a-artifact-vectors.json b/reliability/a2a-artifact-vectors.json index 31740e3..a14adbd 100644 --- a/reliability/a2a-artifact-vectors.json +++ b/reliability/a2a-artifact-vectors.json @@ -1,17 +1,17 @@ { - "$about": "Artifact-level conformance vectors for the A2A artifact-provenance field table forming in a2aproject/A2A#2011. Each vector is a full A2A Artifact whose `Artifact.metadata` carries three flat, separable siblings outside the signed fact bytes, so no sibling can perturb the artifact hash another verifier computes. A conformant consumer checks each axis independently and never lets one alone promote a reading to trusted. This complements conformance-vectors.json (which covers the reliability object alone) by exercising the whole envelope and both states of the anchor sibling.", + "$about": "Historical artifact-level vectors for a proposed A2A artifact-provenance field table. The frozen authorship fixture uses a key now classified compromised, so its signature mathematics may be inspected but its signer must not be policy accepted. Reliability, authorship lifecycle, and provenance remain separate axes.", "field_table": { - "authorship": "Ed25519 signature over the canonical fact (who + integrity). Verify against the key set at `metadata.verify`. canonicalization: json-sorted-compact.", - "provenance_ref": "OPTIONAL existence-in-time commitment (e.g. markovian-provenance/v1: merkle_root, anchor, verifier_url). Two honest states: ABSENT when nothing is anchored, PRESENT when a snapshot is. Its presence proves the bytes existed at a time, never that they are correct.", + "authorship": "Ed25519 signature mathematics over the canonical fact plus separate signer lifecycle. A flat key is insufficient for signer acceptance; use `metadata.lifecycle_registry` and a caller-held revision floor.", + "provenance_ref": "OPTIONAL existence-in-time commitment. ABSENT means no anchor is claimed. PRESENT is only a proof when its scheme is independently and completely validated; mere presence proves nothing.", "reliability": "okf-reliability-v1 object (data quality + observation vantage). Validate against https://dynamicfeed.ai/schemas/okf-reliability-v1.json" }, - "checks": "For each vector: (1) metadata.reliability MUST validate against okf-reliability-v1; (2) metadata.authorship MUST be a well-formed signature block {alg,key_id,canonicalization,sig}; (3) if metadata.provenance_ref is present it MUST be re-checkable against its own verifier; (4) none of the three siblings appears inside the signed fact bytes.", + "checks": "For each vector: (1) metadata.reliability validates independently; (2) authorship signature mathematics and signer lifecycle are reported separately; (3) compromised signers never pass live policy; (4) a provenance proof is only accepted after full independent verification; (5) no sibling silently changes the frozen signed fact bytes.", "vectors": [ { - "label": "dynamicfeed.ai: single-source live reading, anchor ABSENT (honest no-anchor state)", - "expect": "valid", + "label": "dynamicfeed.ai: historical single-source reading, compromised signer, anchor absent", + "expect": "reliability_valid_crypto_inspectable_signer_rejected", "producer": "dynamicfeed.ai", - "note": "Real /a2a artifact. provenance_ref is absent because nothing was anchored; reliability sits at MEDIUM / producer-reported (signed, fresh, one source, so not verified and not independent).", + "note": "Frozen historical artifact. The former signer is compromised under the 2026-07-11 lifecycle disclosure, so no live acceptance is allowed. provenance_ref is absent; reliability remains producer-reported and separate.", "artifact": { "artifactId": "eb8e8ef3-3db9-41bb-9e53-e8d2c746be42", "name": "us_economy result", @@ -23,7 +23,9 @@ "canonicalization": "json-sorted-compact", "sig": "quVFirLVSmSZr2KtEjfCgkk1-EAfs_UWYLwH5R4WLNFcaqd86Hk8b5jp9CmW6CIcfKV4dvz-HdGXfWJ7p3ucBw==" }, - "verify": "https://dynamicfeed.ai/.well-known/keys", + "lifecycle_registry": "https://dynamicfeed.ai/.well-known/signing-key-registry.json", + "signer_lifecycle": "compromised", + "live_policy_accepted": false, "reliability": { "type": "okf-reliability-v1", "confidence": "MEDIUM", diff --git a/scripts/check_rotation_policy.py b/scripts/check_rotation_policy.py new file mode 100644 index 0000000..142556b --- /dev/null +++ b/scripts/check_rotation_policy.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Fail closed when the public verifier source regresses signing-key lifecycle policy.""" + +import json +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parents[1] +OLD = "df-ed25519-4cb32e72f333" +ACTIVE = "df-ed25519-6ca0de29113b" + + +def require(condition, message): + if not condition: + raise SystemExit(f"rotation-policy: FAIL: {message}") + + +registry = json.loads((ROOT / "SIGNING_KEY_LIFECYCLE.json").read_text()) +rust_fixture_registry = json.loads( + (ROOT / "clients/rust/tests/fixtures/signing-key-lifecycle.json").read_text() +) +require(rust_fixture_registry == registry, "Rust packaged lifecycle fixture drifted from root") +require(registry.get("schema") == "df-signing-key-registry/v1", "wrong registry schema") +require(registry.get("registry_revision") == 1, "reviewed revision must remain explicit") +require(registry.get("active_key_id") == ACTIVE, "unexpected active key") +require(registry["lifecycle"][ACTIVE]["status"] == "active", "active key is not active") +require(registry["lifecycle"][OLD]["status"] == "compromised", "old key must remain compromised") +require(registry["lifecycle"][OLD]["compromised_after"] == "2026-07-11T00:00:00Z", + "conservative compromise boundary changed") +require(registry["authority"]["independent_registry_root"] is False, + "current-domain registry must not claim independent authority") + +compat = json.loads((ROOT / "KNOWN_KEYS.json").read_text()) +require(compat.get("deprecated") is True, "KNOWN_KEYS compatibility artifact must be deprecated") +require(compat["keys"][OLD]["status"] == "compromised", "KNOWN_KEYS hides compromise") +require(compat["keys"][OLD]["policy_accepted"] is False, + "KNOWN_KEYS accepts the compromised key") +require(all(entry.get("policy_accepted") is False for entry in compat["keys"].values()), + "KNOWN_KEYS compatibility archive can produce policy acceptance") +require(compat["policy"]["flat_key_acceptance_forbidden"] is True, + "flat key acceptance was re-enabled") + +python_manifest = (ROOT / "clients/python/pyproject.toml").read_text() +javascript_project = json.loads((ROOT / "clients/js/package.json").read_text()) +rust_manifest = (ROOT / "clients/rust/Cargo.toml").read_text() +require(re.search(r'(?m)^name = "dynamicfeed-verify"\nversion = "1\.0\.3"$', python_manifest), + "unexpected Python package identity or release target") +require(javascript_project["version"] == "1.0.2", "unexpected JavaScript release target") +require(re.search(r'(?m)^name = "dynamicfeed-verify"\nversion = "1\.0\.2"$', rust_manifest), + "unexpected Rust package identity or release target") + +python_source = (ROOT / "clients/python/dynamicfeed_verify/__init__.py").read_text() +javascript_source = (ROOT / "clients/js/index.js").read_text() +rust_source = (ROOT / "clients/rust/src/lib.rs").read_text() +csharp_source = (ROOT / "clients/csharp/AwarenessClient.cs").read_text() +agent_source = (ROOT / "examples/verified-agent/agent.py").read_text() +notebook_source = (ROOT / "examples/openai/grounding_on_verifiable_live_data.ipynb").read_text() +for name, source in (("Python", python_source), ("JavaScript", javascript_source), + ("Rust", rust_source)): + require("registry_revision" in source or "registryRevision" in source, + f"{name} lost revision enforcement") + require("historical_snapshot" in source or "HistoricalSnapshot" in source, + f"{name} lost explicit historical mode") +require("_NoRedirect" in python_source, "Python network verification follows redirects") +require("redirect: 'error'" in javascript_source, "JavaScript network verification follows redirects") +require(".redirects(0)" in rust_source, "Rust network verification follows redirects") +require("not explicitly authenticated" in python_source and "not explicitly authenticated" in javascript_source, + "supplied/custom-origin registry can self-authenticate") +require("&& registry.registry_source_authenticated" in rust_source, + "Rust live signer acceptance ignores registry source authentication") +require("QUARANTINED" in csharp_source and "NotSupportedException" in csharp_source, + "C# transport sample became actionable without a verifier") +require("/.well-known/keys" not in agent_source, + "verified-agent regressed to flat-key acceptance") +require("_NoRedirect" in agent_source, "verified-agent network verification follows redirects") +require('"action_authorized": False' in agent_source and "NO ACTION EXECUTED" in agent_source, + "verified-agent became an actuator") +require("/.well-known/keys" not in notebook_source, + "OpenAI example regressed to flat-key acceptance") + +print("rotation-policy: PASS") diff --git a/tests/vectors/README.md b/tests/vectors/README.md index b5a5c94..cf1f54b 100644 --- a/tests/vectors/README.md +++ b/tests/vectors/README.md @@ -3,15 +3,22 @@ Language-agnostic test vectors for the [DF-VERIFY/1 standard](https://dynamicfeed.ai/standard). Use them to prove a verifier, in any language, is byte-for-byte correct. - **`canonicalization.json`**: payloads + their expected canonical output (`json-sorted-compact`: keys sorted recursively, compact separators, non-ASCII escaped `\uXXXX` incl. astral surrogate pairs, top-level `signature` stripped). For each vector, `canonicalize(payload)` must equal `canonical`. -- **`signed-awareness.json`**: a real signed verdict (exact response bytes in `envelope_text`), its verifying public key, and a tampered twin. Verify the **raw text**, because re-serializing a parsed object can change number formatting (e.g. `14.0` → `14`) and break the signature. The `authentic` envelope **must** verify; the `tampered` one **must** be rejected. +- **`signed-awareness.json`**: a real historical signed verdict (exact response bytes in + `envelope_text`), its verifying public key, and a tampered twin. The authentic bytes must be + cryptographically valid, but their now-compromised signer must be lifecycle-rejected. The + tampered twin must be cryptographically rejected. +- **`signed-answer-anchored.json`**: the frozen legacy post-signature anchor convention. Signature + mathematics remains valid when that unsigned anchor changes, and `anchor_authenticated` must be + false; a signed-body change must fail. ## Check an implementation ```bash -python3 tests/verify_vectors.py # Python reference harness → "✓ ALL 10 VECTORS PASS" +python3 tests/verify_vectors.py # Python reference harness node tests/verify_vectors.mjs # JavaScript harness (run `npm install --prefix clients/js` first) ``` -Both reference verifiers (Python and JavaScript) reproduce every vector exactly (same canonical bytes, same signature verdicts). Port a harness to confirm a Go/Rust/etc. verifier. +The harnesses distinguish signature mathematics from lifecycle acceptance. Do not copy the +fixture's flat key map into a live trust policy. Spec: **https://dynamicfeed.ai/standard** diff --git a/tests/vectors/signed-answer-anchored.json b/tests/vectors/signed-answer-anchored.json index b1b78f5..83a7ce0 100644 --- a/tests/vectors/signed-answer-anchored.json +++ b/tests/vectors/signed-answer-anchored.json @@ -1,7 +1,7 @@ { "standard": "DF-VERIFY/1", "schema": "answer/v1", - "description": "An answer/v1 receipt whose `anchor` block is added AFTER signing. A conformant verifier MUST strip BOTH `signature` and `anchor` before recomputing the canonical bytes: `authentic` must verify, `anchor_modified` must ALSO verify (the anchor is not signed; its integrity is checked separately via digest_sha256), and `tampered` (one signed field changed) must be rejected. Verify the RAW text, do not re-serialize.", + "description": "A cryptographic fixture for an answer/v1 receipt whose `anchor` block was added after signing. `authentic` and `anchor_modified` remain mathematically signature-valid with `anchor_authenticated=false`; this does not validate the anchor artifact or signer lifecycle. `tampered` changes a signed field and must be rejected. Verify the raw text.", "public_keys": { "df-ed25519-10ba682c8ad1": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc=" }, @@ -14,4 +14,4 @@ "tampered": { "envelope_text": "{\"anchor\":{\"digest_sha256\":\"6a20b09b0d851d5311d45c84df19d486f457f168579afc65005aed4716c0e4f9\",\"note\":\"added AFTER signing; a verifier MUST strip `anchor` (and `signature`) before checking the Ed25519 signature\",\"status\":\"self_anchor\"},\"checks\":[{\"explain\":\"lt: observed 1.0 vs expected 2.0\",\"id\":\"demo\",\"verdict\":\"supported\"}],\"confidence\":0.92,\"generated_at\":\"2026-07-04T00:00:00Z\",\"issuer\":\"dynamicfeed.ai\",\"receipt_id\":\"ans_testvector0001\",\"schema\":\"answer/v1\",\"signature\":{\"alg\":\"Ed25519\",\"canonicalization\":\"json-sorted-compact\",\"key_id\":\"df-ed25519-10ba682c8ad1\",\"sig\":\"ysdKRUZsvhSlHIdjJz6n8EhDbWBrNiGfKBj2oISIQMeqVC_3qWS6I4tJ8GEcbEFjS0-eGUKGzqhkzti9AfHSBw==\"},\"verdict\":\"contradicted\"}" } -} \ No newline at end of file +} diff --git a/tests/vectors/signed-awareness.json b/tests/vectors/signed-awareness.json index 06f7834..5ec3bf3 100644 --- a/tests/vectors/signed-awareness.json +++ b/tests/vectors/signed-awareness.json @@ -1,21 +1,24 @@ { "standard": "DF-VERIFY/1", "schema": "awareness/v1", - "description": "A real signed verdict (exact response bytes in `envelope_text`), its public key, and a tampered twin (one data byte changed). Verify the RAW text \u2014 do NOT re-serialize a parsed object, or number formatting (e.g. 14.0 -> 14) changes the bytes and breaks the signature. A conformant verifier MUST accept `authentic` and reject `tampered`.", + "description": "A real historical signed verdict (exact response bytes in `envelope_text`), its public key, and a tampered twin. Verify the raw text. The authentic bytes are cryptographically valid, but key df-ed25519-4cb32e72f333 is now compromised and MUST NOT be policy accepted. The tampered bytes must be cryptographically rejected.", "public_keys": { "df-ed25519-4cb32e72f333": "O4Kw2r-BjuDRL_Uyj3Vs8i-SnqHZUtPfARfj27NKEfk=" }, "authentic": { "envelope_text": "{\"snapshot_id\":\"af6cb2e011e7460b9fe0b21b6b049952\",\"issued_at\":\"2026-06-09T04:40:24Z\",\"issuer\":\"dynamicfeed.ai\",\"schema\":\"awareness/v1\",\"robot\":{\"id\":null,\"class\":\"aerial\"},\"location\":{\"lat\":51.5,\"lon\":-0.12,\"alt_m\":null},\"verdict\":{\"status\":\"go\",\"reasons\":[\"all checked conditions within limits\"],\"advisory\":\"Clear to proceed \u2014 all checked conditions within limits.\"},\"facts\":[{\"id\":\"wx.wind_kmh\",\"domain\":\"weather\",\"value\":14.0,\"unit\":\"km/h\",\"observed_at\":\"2026-06-09T05:30+01:00\",\"age_s\":624,\"source\":\"Open-Meteo\",\"source_url\":\"https://open-meteo.com\",\"confidence\":0.95,\"stale\":false},{\"id\":\"wx.conditions\",\"domain\":\"weather\",\"value\":\"Overcast\",\"unit\":null,\"observed_at\":\"2026-06-09T05:30+01:00\",\"age_s\":624,\"source\":\"Open-Meteo\",\"source_url\":\"https://open-meteo.com\",\"confidence\":0.95,\"stale\":false},{\"id\":\"sw.kp_index\",\"domain\":\"space\",\"value\":2.0,\"unit\":\"Kp\",\"observed_at\":\"2026-06-09T00:00:00\",\"age_s\":16824,\"source\":\"NOAA SWPC\",\"source_url\":\"https://www.swpc.noaa.gov\",\"confidence\":0.95,\"stale\":false},{\"id\":\"sw.storm_g\",\"domain\":\"space\",\"value\":0,\"unit\":\"G-scale\",\"observed_at\":\"2026-06-09T00:00:00\",\"age_s\":16824,\"source\":\"NOAA SWPC\",\"source_url\":\"https://www.swpc.noaa.gov\",\"confidence\":0.95,\"stale\":false},{\"id\":\"sw.storm_s\",\"domain\":\"space\",\"value\":0,\"unit\":\"S-scale\",\"observed_at\":\"2026-06-09T00:00:00\",\"age_s\":16824,\"source\":\"NOAA SWPC\",\"source_url\":\"https://www.swpc.noaa.gov\",\"confidence\":0.95,\"stale\":false},{\"id\":\"aq.us_aqi\",\"domain\":\"air_quality\",\"value\":27,\"unit\":\"US AQI\",\"observed_at\":\"2026-06-09T05:00+01:00\",\"age_s\":2424,\"source\":\"Open-Meteo Air Quality\",\"source_url\":\"https://open-meteo.com\",\"confidence\":0.95,\"stale\":false,\"category\":\"Good\"}],\"freshness\":{\"min_age_s\":624,\"max_age_s\":16824,\"stalest_field\":\"sw.kp_index\"},\"degraded\":false,\"degraded_sources\":[],\"note\":\"awareness/v1 (Rung A+B): signed verdict + grounded facts over live feeds; never blocks (hard deadline \u2192 degraded; fail-safe floors to caution, incl. on stale safety data). Verify the Ed25519 signature against GET /.well-known/keys (see scripts/verify_awareness.py). Public chain-anchoring of the signature is Rung C.\",\"signature\":{\"alg\":\"Ed25519\",\"key_id\":\"df-ed25519-4cb32e72f333\",\"canonicalization\":\"json-sorted-compact\",\"sig\":\"zErViGd0X1apGVlJb6y_2ukkzAAu001pdnlCXZLYFUe6rp_Sq7sfSMIGROcRFZ0jtteFiWe1uvPUoeoJt86sBA==\"}}", "expect": { - "ok": true, + "crypto_valid": true, + "policy_accepted": false, + "lifecycle_status": "compromised", "key_id": "df-ed25519-4cb32e72f333" } }, "tampered": { "envelope_text": "{\"snapshot_id\":\"af7cb2e011e7460b9fe0b21b6b049952\",\"issued_at\":\"2026-06-09T04:40:24Z\",\"issuer\":\"dynamicfeed.ai\",\"schema\":\"awareness/v1\",\"robot\":{\"id\":null,\"class\":\"aerial\"},\"location\":{\"lat\":51.5,\"lon\":-0.12,\"alt_m\":null},\"verdict\":{\"status\":\"go\",\"reasons\":[\"all checked conditions within limits\"],\"advisory\":\"Clear to proceed \u2014 all checked conditions within limits.\"},\"facts\":[{\"id\":\"wx.wind_kmh\",\"domain\":\"weather\",\"value\":14.0,\"unit\":\"km/h\",\"observed_at\":\"2026-06-09T05:30+01:00\",\"age_s\":624,\"source\":\"Open-Meteo\",\"source_url\":\"https://open-meteo.com\",\"confidence\":0.95,\"stale\":false},{\"id\":\"wx.conditions\",\"domain\":\"weather\",\"value\":\"Overcast\",\"unit\":null,\"observed_at\":\"2026-06-09T05:30+01:00\",\"age_s\":624,\"source\":\"Open-Meteo\",\"source_url\":\"https://open-meteo.com\",\"confidence\":0.95,\"stale\":false},{\"id\":\"sw.kp_index\",\"domain\":\"space\",\"value\":2.0,\"unit\":\"Kp\",\"observed_at\":\"2026-06-09T00:00:00\",\"age_s\":16824,\"source\":\"NOAA SWPC\",\"source_url\":\"https://www.swpc.noaa.gov\",\"confidence\":0.95,\"stale\":false},{\"id\":\"sw.storm_g\",\"domain\":\"space\",\"value\":0,\"unit\":\"G-scale\",\"observed_at\":\"2026-06-09T00:00:00\",\"age_s\":16824,\"source\":\"NOAA SWPC\",\"source_url\":\"https://www.swpc.noaa.gov\",\"confidence\":0.95,\"stale\":false},{\"id\":\"sw.storm_s\",\"domain\":\"space\",\"value\":0,\"unit\":\"S-scale\",\"observed_at\":\"2026-06-09T00:00:00\",\"age_s\":16824,\"source\":\"NOAA SWPC\",\"source_url\":\"https://www.swpc.noaa.gov\",\"confidence\":0.95,\"stale\":false},{\"id\":\"aq.us_aqi\",\"domain\":\"air_quality\",\"value\":27,\"unit\":\"US AQI\",\"observed_at\":\"2026-06-09T05:00+01:00\",\"age_s\":2424,\"source\":\"Open-Meteo Air Quality\",\"source_url\":\"https://open-meteo.com\",\"confidence\":0.95,\"stale\":false,\"category\":\"Good\"}],\"freshness\":{\"min_age_s\":624,\"max_age_s\":16824,\"stalest_field\":\"sw.kp_index\"},\"degraded\":false,\"degraded_sources\":[],\"note\":\"awareness/v1 (Rung A+B): signed verdict + grounded facts over live feeds; never blocks (hard deadline \u2192 degraded; fail-safe floors to caution, incl. on stale safety data). Verify the Ed25519 signature against GET /.well-known/keys (see scripts/verify_awareness.py). Public chain-anchoring of the signature is Rung C.\",\"signature\":{\"alg\":\"Ed25519\",\"key_id\":\"df-ed25519-4cb32e72f333\",\"canonicalization\":\"json-sorted-compact\",\"sig\":\"zErViGd0X1apGVlJb6y_2ukkzAAu001pdnlCXZLYFUe6rp_Sq7sfSMIGROcRFZ0jtteFiWe1uvPUoeoJt86sBA==\"}}", "expect": { - "ok": false + "crypto_valid": false, + "policy_accepted": false } } } diff --git a/tests/verify_vectors.mjs b/tests/verify_vectors.mjs index 843bd42..6b9e985 100644 --- a/tests/verify_vectors.mjs +++ b/tests/verify_vectors.mjs @@ -12,6 +12,9 @@ import { verify, canonical } from '../clients/js/index.js'; const here = path.dirname(fileURLToPath(import.meta.url)); const VEC = path.join(here, 'vectors'); +const lifecycleRegistry = JSON.parse(fs.readFileSync( + path.join(here, '../SIGNING_KEY_LIFECYCLE.json'), 'utf8' +)); let fails = 0; const cv = JSON.parse(fs.readFileSync(path.join(VEC, 'canonicalization.json'), 'utf8')); @@ -24,21 +27,14 @@ for (const v of cv.vectors) { } const sv = JSON.parse(fs.readFileSync(path.join(VEC, 'signed-awareness.json'), 'utf8')); -const a = await verify(sv.authentic.envelope_text, { jwks: sv.public_keys }); // RAW text — byte-fidelity -const t = await verify(sv.tampered.envelope_text, { jwks: sv.public_keys }); -fails += (!a.ok) + (t.ok === true); -console.log(` [${a.ok ? 'PASS' : 'FAIL'}] signature · authentic envelope verifies`); -console.log(` [${!t.ok ? 'PASS' : 'FAIL'}] signature · tampered envelope rejected`); +const a = await verify(sv.authentic.envelope_text, { lifecycleRegistry }); // RAW text — byte-fidelity +const t = await verify(sv.tampered.envelope_text, { lifecycleRegistry }); +const historicalOk = a.cryptoValid === true && a.ok === false && a.lifecycleStatus === 'compromised'; +const tamperOk = t.cryptoValid === false && t.ok === false; +fails += (!historicalOk) + (!tamperOk); +console.log(` [${historicalOk ? 'PASS' : 'FAIL'}] signature · historical bytes verify, compromised lifecycle rejects`); +console.log(` [${tamperOk ? 'PASS' : 'FAIL'}] signature · tampered envelope rejected`); -const av = JSON.parse(fs.readFileSync(path.join(VEC, 'signed-answer-anchored.json'), 'utf8')); -const aa = await verify(av.authentic.envelope_text, { jwks: av.public_keys }); -const am = await verify(av.anchor_modified.envelope_text, { jwks: av.public_keys }); -const at = await verify(av.tampered.envelope_text, { jwks: av.public_keys }); -fails += (!aa.ok) + (!am.ok) + (at.ok === true); -console.log(` [${aa.ok ? 'PASS' : 'FAIL'}] anchored answer · authentic verifies (anchor stripped)`); -console.log(` [${am.ok ? 'PASS' : 'FAIL'}] anchored answer · anchor-modified STILL verifies (anchor is unsigned)`); -console.log(` [${!at.ok ? 'PASS' : 'FAIL'}] anchored answer · tampered signed field rejected`); - -const n = cv.vectors.length + 5; +const n = cv.vectors.length + 2; console.log(`\n${fails ? '✗ ' + fails + ' FAILED' : '✓ ALL ' + n + ' VECTORS PASS'}`); process.exit(fails ? 1 : 0); diff --git a/tests/verify_vectors.py b/tests/verify_vectors.py index b4fc9ad..77d2480 100644 --- a/tests/verify_vectors.py +++ b/tests/verify_vectors.py @@ -14,33 +14,29 @@ Exit code 0 = all vectors pass. The vectors themselves (tests/vectors/*.json) are language-agnostic; port this harness to confirm a JS/Go/Rust/etc. verifier byte-for-byte. """ -import base64 import json import pathlib import sys -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey - VEC = pathlib.Path(__file__).parent / "vectors" +ROOT = VEC.parents[1] +sys.path.insert(0, str(ROOT / "clients" / "python")) +from dynamicfeed_verify import canonical as client_canonical # noqa: E402 +from dynamicfeed_verify import verify as client_verify # noqa: E402 -def canonical(payload): - p = {k: v for k, v in payload.items() if k not in ("signature", "anchor")} if isinstance(payload, dict) else payload - return json.dumps(p, sort_keys=True, separators=(",", ":")).encode("utf-8") +REGISTRY = json.loads((ROOT / "SIGNING_KEY_LIFECYCLE.json").read_text()) -def _b64(s): - return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) +def canonical(payload): + p = {k: v for k, v in payload.items() if k != "signature"} if isinstance(payload, dict) else payload + return client_canonical(p) def verify(env, keys): - sig = env.get("signature") or {} - payload = {k: v for k, v in env.items() if k != "signature"} - try: - Ed25519PublicKey.from_public_bytes(_b64(keys[sig["key_id"]])).verify(_b64(sig["sig"]), canonical(payload)) - return True - except Exception: - return False + result = client_verify(env, jwks=keys) + assert result["ok"] is False, "flat key bytes must never imply lifecycle acceptance" + return result.get("crypto_valid") is True def main(): @@ -63,6 +59,14 @@ def main(): print(f" [{'PASS' if a else 'FAIL'}] signature · authentic envelope verifies") print(f" [{'PASS' if not t else 'FAIL'}] signature · tampered envelope rejected") + lifecycle = client_verify(json.loads(sv["authentic"]["envelope_text"]), + lifecycle_registry=REGISTRY) + lifecycle_ok = (lifecycle.get("crypto_valid") is True + and lifecycle.get("ok") is False + and lifecycle.get("lifecycle_status") == "compromised") + fails += not lifecycle_ok + print(f" [{'PASS' if lifecycle_ok else 'FAIL'}] lifecycle · compromised historical signer rejected") + av = json.loads((VEC / "signed-answer-anchored.json").read_text()) akeys = av["public_keys"] aa = verify(json.loads(av["authentic"]["envelope_text"]), akeys) @@ -73,7 +77,7 @@ def main(): print(f" [{'PASS' if am else 'FAIL'}] anchored answer · anchor-modified STILL verifies (anchor is unsigned)") print(f" [{'PASS' if not at else 'FAIL'}] anchored answer · tampered signed field rejected") - n = len(cv["vectors"]) + 5 + n = len(cv["vectors"]) + 6 print(f"\n{'✓ ALL ' + str(n) + ' VECTORS PASS' if not fails else '✗ ' + str(fails) + ' FAILED'}") return 1 if fails else 0 From 2497e90f0384435dd0d8a8a4486a1e300d5f435f Mon Sep 17 00:00:00 2001 From: Sami Mechkor Date: Sun, 12 Jul 2026 00:29:03 +1000 Subject: [PATCH 2/4] security: reject malleable base64url signatures --- clients/python/dynamicfeed_verify/__init__.py | 39 ++++++++++++++----- clients/python/tests/test_anchor.py | 28 ++++++++++++- examples/verified-agent/agent.py | 32 +++++++++++---- examples/verified-agent/test_agent.py | 21 ++++++++++ scripts/check_rotation_policy.py | 7 ++++ 5 files changed, 108 insertions(+), 19 deletions(-) diff --git a/clients/python/dynamicfeed_verify/__init__.py b/clients/python/dynamicfeed_verify/__init__.py index ff2b96d..4a18fae 100644 --- a/clients/python/dynamicfeed_verify/__init__.py +++ b/clients/python/dynamicfeed_verify/__init__.py @@ -46,13 +46,32 @@ def _urlopen_no_redirect(request, timeout): return _NO_REDIRECT_OPENER.open(request, timeout=timeout) -def _b64d(s: str) -> bytes: - """Strictly decode padded or unpadded base64url.""" - padding = len(s) - len(s.rstrip("=")) if isinstance(s, str) else 0 - if not isinstance(s, str) or not re.fullmatch(r"[A-Za-z0-9_-]+={0,2}", s) \ - or len(s.rstrip("=")) % 4 == 1 or (padding and len(s) % 4 != 0): - raise ValueError("invalid base64url") - return base64.b64decode(s + "=" * (-len(s) % 4), altchars=b"-_", validate=True) +def _b64d(s: str, expected_length: Optional[int] = None) -> bytes: + """Strictly decode canonical padded or unpadded base64url. + + ``base64.urlsafe_b64decode`` silently discards non-alphabet bytes. That is unsafe for an + unsigned signature container because multiple signature strings can otherwise decode to the + same Ed25519 bytes. Enforce the URL-safe alphabet, exact padding, canonical unused bits, and + (where supplied) the protocol object's exact decoded length before returning any bytes. + """ + if not isinstance(s, str) or re.fullmatch(r"[A-Za-z0-9_-]+={0,2}", s) is None: + raise ValueError("invalid base64url alphabet") + unpadded = s.rstrip("=") + supplied_padding = len(s) - len(unpadded) + if len(unpadded) % 4 == 1: + raise ValueError("invalid base64url length") + required_padding = (-len(unpadded)) % 4 + if supplied_padding not in (0, required_padding): + raise ValueError("invalid base64url padding") + decoded = base64.b64decode( + unpadded + "=" * required_padding, altchars=b"-_", validate=True, + ) + canonical_unpadded = base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") + if unpadded != canonical_unpadded: + raise ValueError("non-canonical base64url encoding") + if expected_length is not None and len(decoded) != expected_length: + raise ValueError(f"base64url value must decode to exactly {expected_length} bytes") + return decoded def _unique_object(pairs): @@ -129,7 +148,7 @@ def validate_lifecycle_registry(raw: dict, *, mode: str = "live", raise ValueError("incomplete signing-key registry") active = [] for key_id, encoded in keys.items(): - public_raw = _b64d(encoded) + public_raw = _b64d(encoded, expected_length=32) fingerprint = hashlib.sha256(public_raw).hexdigest() if len(public_raw) != 32 or key_id != "df-ed25519-" + fingerprint[:12]: raise ValueError(f"public key does not derive {key_id}") @@ -295,8 +314,8 @@ def verify(envelope: dict, jwks: Optional[dict] = None, base: str = DEFAULT_BASE "lifecycle_status": "unknown", "key_id": kid, "error": f"key_id {kid} not in supplied trust material"} try: - public_raw = _b64d(keys[kid]) - sig_bytes = _b64d(sig_b64) + public_raw = _b64d(keys[kid], expected_length=32) + sig_bytes = _b64d(sig_b64, expected_length=64) except Exception as exc: return {"ok": False, "crypto_valid": False, "signer_accepted": False, "lifecycle_status": "unknown", "key_id": kid, diff --git a/clients/python/tests/test_anchor.py b/clients/python/tests/test_anchor.py index bee64d8..0cff479 100644 --- a/clients/python/tests/test_anchor.py +++ b/clients/python/tests/test_anchor.py @@ -122,7 +122,7 @@ def test_strict_json_base64_and_timestamp_inputs_fail_closed(): pass else: raise AssertionError(f"non-strict JSON accepted: {raw}") - for encoded in ("!!!!", "A", "AA=", "abc$", "===="): + for encoded in ("!!!!", "A", "AA=", "AA===", "+A==", "AB", "abc$", "===="): try: _b64d(encoded) except ValueError: @@ -146,6 +146,32 @@ def test_strict_json_base64_and_timestamp_inputs_fail_closed(): assert "encoding" in result["error"] +def test_signature_text_malleability_and_wrong_decoded_lengths_fail_closed(): + env = _load("awareness_noanchor.json") + signature_text = env["signature"]["sig"] + assert len(_b64d(signature_text, expected_length=64)) == 64 + + # Python's permissive urlsafe_b64decode accepts both of these mutations as the same 64 bytes. + # The signature block is outside the signed payload, so accepting either spelling would create + # a cross-verifier signature-text malleability gap. + for mutated_text in ("!" + signature_text, signature_text + "!"): + mutated = copy.deepcopy(env) + mutated["signature"]["sig"] = mutated_text + result = verify( + mutated, lifecycle_registry=REGISTRY, registry_source_authenticated=True, + ) + assert result["ok"] is False and result["crypto_valid"] is False + assert "encoding" in result["error"] + + for encoded, expected_length in (("AA", 64), ("AA", 32)): + try: + _b64d(encoded, expected_length=expected_length) + except ValueError as exc: + assert "exactly" in str(exc) + else: + raise AssertionError("wrong decoded length accepted") + + def test_custom_network_origin_does_not_self_authenticate(): env = _load("awareness_noanchor.json") key_id = env["signature"]["key_id"] diff --git a/examples/verified-agent/agent.py b/examples/verified-agent/agent.py index e074df9..07118ff 100644 --- a/examples/verified-agent/agent.py +++ b/examples/verified-agent/agent.py @@ -13,6 +13,7 @@ import base64 import hashlib import json +import re import urllib.request from datetime import datetime, timezone from typing import Optional @@ -34,11 +35,26 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): _NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect) -def _b64(value: str) -> bytes: - if not isinstance(value, str): - raise ValueError("base64url value must be text") - padded = value + "=" * (-len(value) % 4) - return base64.b64decode(padded, altchars=b"-_", validate=True) +def _b64(value: str, expected_length: Optional[int] = None) -> bytes: + """Decode only canonical padded or unpadded base64url of the expected byte length.""" + if not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9_-]+={0,2}", value) is None: + raise ValueError("invalid base64url alphabet") + unpadded = value.rstrip("=") + supplied_padding = len(value) - len(unpadded) + if len(unpadded) % 4 == 1: + raise ValueError("invalid base64url length") + required_padding = (-len(unpadded)) % 4 + if supplied_padding not in (0, required_padding): + raise ValueError("invalid base64url padding") + decoded = base64.b64decode( + unpadded + "=" * required_padding, altchars=b"-_", validate=True, + ) + canonical_unpadded = base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") + if unpadded != canonical_unpadded: + raise ValueError("non-canonical base64url encoding") + if expected_length is not None and len(decoded) != expected_length: + raise ValueError(f"base64url value must decode to exactly {expected_length} bytes") + return decoded def _unique_object(pairs): @@ -106,7 +122,7 @@ def _validate_registry(registry: dict, minimum_revision: int = COMPILED_MIN_REGI raise ValueError("incomplete signing-key registry") active = [] for key_id, encoded in keys.items(): - public_raw = _b64(encoded) + public_raw = _b64(encoded, expected_length=32) fingerprint = hashlib.sha256(public_raw).hexdigest() if len(public_raw) != 32 or key_id != "df-ed25519-" + fingerprint[:12]: raise ValueError(f"public key does not derive {key_id}") @@ -220,8 +236,8 @@ def verify(env: dict, base: str = BASE, lifecycle_registry: Optional[dict] = Non "independent_trust_root": False, "error": "key absent from lifecycle registry"} try: - public_raw = _b64(encoded_key) - signature_raw = _b64(signature_text) + public_raw = _b64(encoded_key, expected_length=32) + signature_raw = _b64(signature_text, expected_length=64) except Exception as exc: return {"ok": False, "crypto_valid": False, "signer_accepted": False, "lifecycle_status": "unknown", "key_id": key_id, diff --git a/examples/verified-agent/test_agent.py b/examples/verified-agent/test_agent.py index 40e567b..8692e38 100644 --- a/examples/verified-agent/test_agent.py +++ b/examples/verified-agent/test_agent.py @@ -121,6 +121,27 @@ def test_strict_json_and_metadata(self): envelope, lifecycle_registry=self.registry, registry_source_authenticated=True, )["ok"]) + def test_signature_text_malleability_is_rejected(self): + envelope = self.envelope(self.active_private, self.active_id) + signature_text = envelope["signature"]["sig"] + for mutated_text in ("!!!" + signature_text, signature_text + "!!!"): + mutated = json.loads(json.dumps(envelope)) + mutated["signature"]["sig"] = mutated_text + result = agent.verify( + mutated, + lifecycle_registry=self.registry, + registry_source_authenticated=True, + ) + self.assertFalse(result["ok"]) + self.assertFalse(result["crypto_valid"]) + self.assertIn("encoding", result["error"]) + + for malformed in ("A", "AA=", "AA===", "+A==", "AB", "abc$"): + with self.assertRaises(ValueError): + agent._b64(malformed) + with self.assertRaisesRegex(ValueError, "exactly 32 bytes"): + agent._b64("AA", expected_length=32) + def test_registry_revision_is_positive_and_meets_floor(self): for revision in (None, 0, True): bad = json.loads(json.dumps(self.registry)) diff --git a/scripts/check_rotation_policy.py b/scripts/check_rotation_policy.py index 142556b..af3df93 100644 --- a/scripts/check_rotation_policy.py +++ b/scripts/check_rotation_policy.py @@ -62,6 +62,13 @@ def require(condition, message): require("historical_snapshot" in source or "HistoricalSnapshot" in source, f"{name} lost explicit historical mode") require("_NoRedirect" in python_source, "Python network verification follows redirects") +for name, source in (("Python verifier", python_source), ("verified-agent", agent_source)): + require('re.fullmatch(r"[A-Za-z0-9_-]+={0,2}"' in source, + f"{name} lost strict base64url alphabet/padding validation") + require("canonical_unpadded" in source, + f"{name} lost canonical base64url encoding validation") + require("expected_length=32" in source and "expected_length=64" in source, + f"{name} lost exact Ed25519 public-key/signature length checks") require("redirect: 'error'" in javascript_source, "JavaScript network verification follows redirects") require(".redirects(0)" in rust_source, "Rust network verification follows redirects") require("not explicitly authenticated" in python_source and "not explicitly authenticated" in javascript_source, From 43ab855d80b1eff5478a9f7167b06c5b9a1b66f5 Mon Sep 17 00:00:00 2001 From: Sami Mechkor Date: Sun, 12 Jul 2026 01:50:54 +1000 Subject: [PATCH 3/4] security: reject noncanonical JavaScript key encodings --- clients/js/index.js | 32 +++++++++++------ clients/js/tests/anchor.test.js | 62 +++++++++++++++++++++++++++++++- scripts/check_rotation_policy.py | 6 ++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/clients/js/index.js b/clients/js/index.js index 670aedc..e1a3846 100644 --- a/clients/js/index.js +++ b/clients/js/index.js @@ -12,16 +12,26 @@ import * as ed from '@noble/ed25519'; export const DEFAULT_BASE = 'https://dynamicfeed.ai'; export const COMPILED_MIN_REGISTRY_REVISION = 1; -function b64u(s) { - const padding = typeof s === 'string' ? ((s.match(/=+$/) || [''])[0].length) : 0; - const coreLength = typeof s === 'string' ? s.length - padding : 0; - if (typeof s !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(s) || coreLength % 4 === 1 - || (padding && s.length % 4 !== 0)) - throw new Error('invalid base64url'); - s = s.replace(/-/g, '+').replace(/_/g, '/'); - s += '='.repeat((4 - (s.length % 4)) % 4); - const bin = atob(s), u = new Uint8Array(bin.length); +function b64u(s, expectedLength) { + if (typeof s !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(s)) + throw new Error('invalid base64url alphabet'); + const unpadded = s.replace(/=+$/, ''); + const suppliedPadding = s.length - unpadded.length; + if (unpadded.length % 4 === 1) throw new Error('invalid base64url length'); + const requiredPadding = (4 - (unpadded.length % 4)) % 4; + if (suppliedPadding !== 0 && suppliedPadding !== requiredPadding) + throw new Error('invalid base64url padding'); + const normalized = unpadded.replace(/-/g, '+').replace(/_/g, '/') + + '='.repeat(requiredPadding); + const bin = atob(normalized), u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); + let decodedText = ''; + for (const byte of u) decodedText += String.fromCharCode(byte); + const canonicalUnpadded = btoa(decodedText).replace(/\+/g, '-').replace(/\//g, '_') + .replace(/=+$/, ''); + if (unpadded !== canonicalUnpadded) throw new Error('non-canonical base64url encoding'); + if (expectedLength !== undefined && u.length !== expectedLength) + throw new Error('base64url value must decode to exactly ' + expectedLength + ' bytes'); return u; } @@ -106,7 +116,7 @@ export async function validateLifecycleRegistry(raw, opts = {}) { if (!raw || ty revisionContext(raw, opts); utcMillis(raw.updated_at, 'updated_at'); const keys = raw.public_keys, life = raw.lifecycle, activeId = raw.active_key_id; if (!keys || !life || typeof keys !== 'object' || typeof life !== 'object' || Array.isArray(keys) || Array.isArray(life)) throw new Error('invalid lifecycle registry maps'); const ids = Object.keys(keys).sort(), lids = Object.keys(life).sort(); if (!ids.length || ids.length !== lids.length || ids.some((x, i) => x !== lids[i])) throw new Error('incomplete lifecycle registry'); - let active = 0; for (const id of ids) { const pub = b64u(keys[id]); if (pub.length !== 32 || await keyIdFor(pub) !== id) throw new Error('key id binding mismatch'); + let active = 0; for (const id of ids) { const pub = b64u(keys[id], 32); if (await keyIdFor(pub) !== id) throw new Error('key id binding mismatch'); const meta = life[id]; if (!meta || meta.alg !== 'Ed25519' || meta.use !== 'receipt-signing' || !['active', 'retired', 'compromised'].includes(meta.status)) throw new Error('invalid lifecycle entry'); const fingerprint = [...new Uint8Array(await crypto.subtle.digest('SHA-256', pub))].map(b => b.toString(16).padStart(2, '0')).join(''); if (meta.fingerprint_sha256 !== fingerprint || meta.public_key_retained !== true) throw new Error('lifecycle fingerprint/retention mismatch'); @@ -197,7 +207,7 @@ export async function verify(input, opts = {}) { const drops = [['signature']]; if (root.v.some(p => p[0] === 'anchor')) drops.push(['signature', 'anchor']); let sigBytes, pk; - try { sigBytes = b64u(sigB64); pk = b64u(ks[keyId]); } + try { sigBytes = b64u(sigB64, 64); pk = b64u(ks[keyId], 32); } catch (e) { return { ok: false, cryptoValid: false, signerAccepted: false, lifecycleStatus: 'unknown', error: 'invalid signature or public-key encoding — ' + e.message, keyId }; } if (sigBytes.length !== 64) return { ok: false, cryptoValid: false, signerAccepted: false, diff --git a/clients/js/tests/anchor.test.js b/clients/js/tests/anchor.test.js index 0e360a5..6fab1c5 100644 --- a/clients/js/tests/anchor.test.js +++ b/clients/js/tests/anchor.test.js @@ -19,6 +19,25 @@ const lifecycleRegistry = JSON.parse(readFileSync( const read = (n) => readFileSync(join(FIX, n), 'utf8'); const jwks = (pkFile, kid) => ({ [kid]: read(pkFile).trim() }); const kidOf = (text) => JSON.parse(text).signature.key_id; +const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +function mutateUnusedBits(encoded, unusedBits) { + const padding = (encoded.match(/=+$/) || [''])[0]; + const unpadded = encoded.slice(0, encoded.length - padding.length); + const lastIndex = B64URL.indexOf(unpadded.at(-1)); + assert.ok(lastIndex >= 0, 'test input must end in base64url data'); + assert.strictEqual(lastIndex & ((1 << unusedBits) - 1), 0, + 'test input must use a canonical final character'); + return unpadded.slice(0, -1) + B64URL[lastIndex + 1] + padding; +} + +function replaceSignatureText(rawEnvelope, replacement) { + const original = JSON.parse(rawEnvelope).signature.sig; + const needle = `"sig":"${original}"`; + assert.strictEqual(rawEnvelope.split(needle).length, 2, + 'fixture must contain exactly one signature value'); + return rawEnvelope.replace(needle, `"sig":"${replacement}"`); +} async function run() { // 1) anchored /v1/answer must verify VALID (the previously-broken case) @@ -75,6 +94,47 @@ async function run() { assert.strictEqual(malformedResult.cryptoValid, false); assert.match(malformedResult.error, /encoding/); + // The signature block is outside the signed payload. A 64-byte value has four unused bits in + // its final base64url character, so a permissive decoder can accept 15 alternate spellings for + // exactly the same Ed25519 bytes. Preserve the raw fixture bytes and mutate only that spelling. + const signatureText = JSON.parse(aw).signature.sig; + const malleableSignatureText = mutateUnusedBits(signatureText, 4); + assert.deepStrictEqual(Buffer.from(malleableSignatureText, 'base64url'), + Buffer.from(signatureText, 'base64url'), 'mutation must preserve decoded signature bytes'); + const malleableSignature = await verify( + replaceSignatureText(aw, malleableSignatureText), + { lifecycleRegistry, registrySourceAuthenticated: true }, + ); + assert.strictEqual(malleableSignature.ok, false); + assert.strictEqual(malleableSignature.cryptoValid, false); + assert.match(malleableSignature.error, /non-canonical base64url encoding/); + + // A 32-byte public key has two unused bits in its final base64url character. The lifecycle + // registry must reject an alternate spelling even though it decodes to the same key bytes. + const publicKeyText = lifecycleRegistry.public_keys[kidOf(aw)]; + const malleablePublicKeyText = mutateUnusedBits(publicKeyText, 2); + assert.deepStrictEqual(Buffer.from(malleablePublicKeyText, 'base64url'), + Buffer.from(publicKeyText, 'base64url'), 'mutation must preserve decoded public-key bytes'); + const malleablePublicKey = structuredClone(lifecycleRegistry); + malleablePublicKey.public_keys[kidOf(aw)] = malleablePublicKeyText; + await assert.rejects(() => validateLifecycleRegistry(malleablePublicKey), + /non-canonical base64url encoding/); + + const shortSignature = replaceSignatureText(aw, 'AA'); + const shortSignatureResult = await verify(shortSignature, { + lifecycleRegistry, registrySourceAuthenticated: true, + }); + assert.strictEqual(shortSignatureResult.ok, false); + assert.strictEqual(shortSignatureResult.cryptoValid, false); + assert.match(shortSignatureResult.error, /exactly 64 bytes/); + const shortPublicKey = structuredClone(lifecycleRegistry); + shortPublicKey.public_keys[kidOf(aw)] = 'AA'; + await assert.rejects(() => validateLifecycleRegistry(shortPublicKey), /exactly 32 bytes/); + + const unpaddedRegistry = structuredClone(lifecycleRegistry); + unpaddedRegistry.public_keys[kidOf(aw)] = publicKeyText.replace(/=+$/, ''); + await validateLifecycleRegistry(unpaddedRegistry); + // 5) historical inspection is explicit and can never turn the overall result green. const historical = await verify(aw, { lifecycleRegistry, verificationMode: 'historical_snapshot', minimumRegistryRevision: 2, registrySourceAuthenticated: true }); @@ -122,7 +182,7 @@ async function run() { globalThis.fetch = previousFetch; } - console.log('PASS — historical signature math valid, compromised lifecycle rejected, tamper caught'); + console.log('PASS — lifecycle, canonical base64url, exact lengths, signature math, and tamper checks'); } run().catch((e) => { diff --git a/scripts/check_rotation_policy.py b/scripts/check_rotation_policy.py index af3df93..e1e5c83 100644 --- a/scripts/check_rotation_policy.py +++ b/scripts/check_rotation_policy.py @@ -69,6 +69,12 @@ def require(condition, message): f"{name} lost canonical base64url encoding validation") require("expected_length=32" in source and "expected_length=64" in source, f"{name} lost exact Ed25519 public-key/signature length checks") +require("canonicalUnpadded" in javascript_source, + "JavaScript lost canonical base64url encoding validation") +require("b64u(keys[id], 32)" in javascript_source and "b64u(ks[keyId], 32)" in javascript_source, + "JavaScript lost exact Ed25519 public-key length checks") +require("b64u(sigB64, 64)" in javascript_source, + "JavaScript lost exact Ed25519 signature length checks") require("redirect: 'error'" in javascript_source, "JavaScript network verification follows redirects") require(".redirects(0)" in rust_source, "Rust network verification follows redirects") require("not explicitly authenticated" in python_source and "not explicitly authenticated" in javascript_source, From c191d8323e11b3dc68cf26b38e18f9a229f1da6c Mon Sep 17 00:00:00 2001 From: Sami Mechkor Date: Sun, 12 Jul 2026 01:59:46 +1000 Subject: [PATCH 4/4] ci: test packaged verifier artifacts --- .github/workflows/ci.yml | 19 +++++++++++-- clients/js/tests/anchor.test.js | 11 ++++++-- clients/python/tests/test_anchor.py | 33 ++++++++++++++++++++++ clients/rust/tests/conformance.rs | 44 +++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d157758..20f8a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,13 @@ jobs: run: python3 tests/verify_vectors.py - name: package build run: python -m build clients/python + - name: clean-wheel lifecycle and encoding regressions + run: | + clean_venv="$RUNNER_TEMP/dynamicfeed-verify-wheel" + python -m venv "$clean_venv" + "$clean_venv/bin/pip" install pytest clients/python/dist/*.whl + cd "$RUNNER_TEMP" + "$clean_venv/bin/python" -m pytest "$GITHUB_WORKSPACE/clients/python/tests" - name: notebook JSON integrity run: python -m json.tool examples/openai/grounding_on_verifiable_live_data.ipynb >/dev/null - name: reliability self-test @@ -47,9 +54,15 @@ jobs: run: npm test --prefix clients/js - name: conformance vectors run: node tests/verify_vectors.mjs - - name: package contents - working-directory: clients/js - run: npm pack --dry-run + - name: package and test clean tarball + run: | + package_dir="$RUNNER_TEMP/dynamicfeed-verify-package" + consumer_dir="$RUNNER_TEMP/dynamicfeed-verify-consumer" + mkdir -p "$package_dir" "$consumer_dir" + (cd clients/js && npm pack --pack-destination "$package_dir") + npm install --prefix "$consumer_dir" "$package_dir"/*.tgz + DF_VERIFY_PACKAGE_ENTRY="$consumer_dir/node_modules/@dynamicfeed/verify/index.js" \ + node clients/js/tests/anchor.test.js - name: reliability self-test run: node reliability/verify_reliability.js --selftest diff --git a/clients/js/tests/anchor.test.js b/clients/js/tests/anchor.test.js index 6fab1c5..9a10725 100644 --- a/clients/js/tests/anchor.test.js +++ b/clients/js/tests/anchor.test.js @@ -8,9 +8,14 @@ // Run: node clients/js/tests/anchor.test.js import assert from 'node:assert'; import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { validateLifecycleRegistry, verify } from '../index.js'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const verifierEntry = process.env.DF_VERIFY_PACKAGE_ENTRY; +const verifierModule = verifierEntry + ? await import(pathToFileURL(resolve(verifierEntry)).href) + : await import('../index.js'); +const { validateLifecycleRegistry, verify } = verifierModule; const FIX = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); const lifecycleRegistry = JSON.parse(readFileSync( diff --git a/clients/python/tests/test_anchor.py b/clients/python/tests/test_anchor.py index 0cff479..c8f6f65 100644 --- a/clients/python/tests/test_anchor.py +++ b/clients/python/tests/test_anchor.py @@ -7,6 +7,7 @@ Run: python -m pytest clients/python/tests (or) python clients/python/tests/test_anchor.py """ +import base64 import copy import json import pathlib @@ -163,6 +164,38 @@ def test_signature_text_malleability_and_wrong_decoded_lengths_fail_closed(): assert result["ok"] is False and result["crypto_valid"] is False assert "encoding" in result["error"] + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + + def mutate_unused_bits(encoded: str, unused_bits: int) -> str: + unpadded = encoded.rstrip("=") + padding = encoded[len(unpadded):] + last_index = alphabet.index(unpadded[-1]) + assert last_index & ((1 << unused_bits) - 1) == 0 + return unpadded[:-1] + alphabet[last_index + 1] + padding + + # A permissive decoder treats each mutation as the same bytes. The verifier must reject the + # alternate spelling before Ed25519 verification because the signature block is unsigned. + malleable_signature = mutate_unused_bits(signature_text, 4) + assert base64.urlsafe_b64decode(malleable_signature) == base64.urlsafe_b64decode(signature_text) + mutated = copy.deepcopy(env) + mutated["signature"]["sig"] = malleable_signature + result = verify(mutated, lifecycle_registry=REGISTRY, registry_source_authenticated=True) + assert result["ok"] is False and result["crypto_valid"] is False + assert "non-canonical base64url encoding" in result["error"] + + key_id = env["signature"]["key_id"] + public_key = REGISTRY["public_keys"][key_id] + malleable_public_key = mutate_unused_bits(public_key, 2) + assert base64.urlsafe_b64decode(malleable_public_key) == base64.urlsafe_b64decode(public_key) + malformed_registry = copy.deepcopy(REGISTRY) + malformed_registry["public_keys"][key_id] = malleable_public_key + try: + validate_lifecycle_registry(malformed_registry) + except ValueError as exc: + assert "non-canonical base64url encoding" in str(exc) + else: + raise AssertionError("non-canonical public-key spelling accepted") + for encoded, expected_length in (("AA", 64), ("AA", 32)): try: _b64d(encoded, expected_length=expected_length) diff --git a/clients/rust/tests/conformance.rs b/clients/rust/tests/conformance.rs index fd4341e..46123d4 100644 --- a/clients/rust/tests/conformance.rs +++ b/clients/rust/tests/conformance.rs @@ -49,6 +49,23 @@ fn generated_active_material() -> (String, String) { (envelope, registry) } +fn mutate_unused_bits(encoded: &str, unused_bits: u8) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let padding = encoded.len() - encoded.trim_end_matches('=').len(); + let core = encoded.trim_end_matches('='); + let last = *core.as_bytes().last().unwrap(); + let index = ALPHABET + .iter() + .position(|candidate| *candidate == last) + .unwrap(); + assert_eq!(index & ((1usize << unused_bits) - 1), 0); + let mut mutated = core.as_bytes().to_vec(); + *mutated.last_mut().unwrap() = ALPHABET[index + 1]; + let mut result = String::from_utf8(mutated).unwrap(); + result.push_str(&"=".repeat(padding)); + result +} + #[test] fn compromised_fixture_is_crypto_valid_but_policy_rejected() { assert!( @@ -171,3 +188,30 @@ fn duplicate_keys_and_rewritten_signature_metadata_fail_closed() { malformed.replace_range(signature_start..signature_end, "!!!!"); assert!(verify_envelope(&malformed, &public).is_err()); } + +#[test] +fn noncanonical_unused_bits_and_wrong_lengths_fail_closed() { + let (envelope, registry_json) = generated_active_material(); + let public = URL_SAFE_NO_PAD.encode( + SigningKey::from_bytes(&[7u8; 32]) + .verifying_key() + .to_bytes(), + ); + let signature_start = envelope.find("\"sig\":\"").unwrap() + 7; + let signature_end = signature_start + envelope[signature_start..].find('"').unwrap(); + let signature = &envelope[signature_start..signature_end]; + + let malleable_signature = mutate_unused_bits(signature, 4); + let mut malformed_envelope = envelope.clone(); + malformed_envelope.replace_range(signature_start..signature_end, malleable_signature.as_str()); + assert!(verify_envelope(&malformed_envelope, &public).is_err()); + + let malleable_public = mutate_unused_bits(&public, 2); + assert!(verify_envelope(&envelope, &malleable_public).is_err()); + let malformed_registry = registry_json.replacen(&public, &malleable_public, 1); + assert!(LifecycleRegistry::parse(&malformed_registry).is_err()); + + let short_signature = envelope.replacen(signature, "AA", 1); + assert!(verify_envelope(&short_signature, &public).is_err()); + assert!(verify_envelope(&envelope, "AA").is_err()); +}