diff --git a/.github/workflows/ots-temporal-anchor-conformance.yml b/.github/workflows/ots-temporal-anchor-conformance.yml new file mode 100644 index 0000000..14fe8ec --- /dev/null +++ b/.github/workflows/ots-temporal-anchor-conformance.yml @@ -0,0 +1,37 @@ +name: OTS temporal-anchor conformance + +on: + pull_request: + paths: + - "scripts/verify_ots_anchor.py" + - "scripts/check_ots_anchor_vectors.py" + - "conformance/guardrail-decision-v1/ots/**" + - "docs/OTS_TEMPORAL_ANCHOR_PROFILE.md" + - ".github/workflows/ots-temporal-anchor-conformance.yml" + push: + branches: + - main + paths: + - "scripts/verify_ots_anchor.py" + - "scripts/check_ots_anchor_vectors.py" + - "conformance/guardrail-decision-v1/ots/**" + - "docs/OTS_TEMPORAL_ANCHOR_PROFILE.md" + - ".github/workflows/ots-temporal-anchor-conformance.yml" + +permissions: + contents: read + +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Run OTS parser vectors + run: | + python3 scripts/check_ots_anchor_vectors.py \ + conformance/guardrail-decision-v1/ots/manifest.json diff --git a/conformance/guardrail-decision-v1/ots/README.md b/conformance/guardrail-decision-v1/ots/README.md new file mode 100644 index 0000000..4e92397 --- /dev/null +++ b/conformance/guardrail-decision-v1/ots/README.md @@ -0,0 +1,46 @@ +# Bitcoin OpenTimestamps temporal-anchor vectors + +This corpus tests the bounded adapter that turns official `ots verify` output +into a ProofPath temporal-anchor result. + +It does **not** implement OpenTimestamps cryptography itself. Live verification +is delegated to the official OpenTimestamps client. The deterministic corpus +tests the fail-closed classification boundary around that client. + +## Run the deterministic corpus + +```bash +python3 scripts/check_ots_anchor_vectors.py \ + conformance/guardrail-decision-v1/ots/manifest.json +``` + +Expected result: eight passing vectors. + +## Live verification + +Install the official client and provide access to a Bitcoin Core node: + +```bash +pip install opentimestamps-client + +python3 scripts/verify_ots_anchor.py \ + decision.json \ + decision.json.ots \ + --bitcoin-node http://USER:PASS@127.0.0.1:8332 \ + --output temporal-anchor-result.json +``` + +The verifier copies the exact target and proof into a temporary directory as +`payload` and `payload.ots`, then invokes `ots verify payload.ots`. This prevents +the proof from being accidentally checked against a different neighboring file. + +## Result states + +| State | Meaning | +| --- | --- | +| `TEMPORALLY_ANCHORED` | The official client exited successfully and reported exactly one Bitcoin block attestation. | +| `PENDING` | The proof is waiting for Bitcoin confirmation or calendar completion. | +| `INVALID` | The proof/output is conflicting, malformed, or does not contain a Bitcoin attestation. | +| `UNAVAILABLE` | The client/node/runtime was unavailable, so no temporal claim is made. | + +A `temporal_anchor_ref` URL alone never reaches `TEMPORALLY_ANCHORED`. diff --git a/conformance/guardrail-decision-v1/ots/manifest.json b/conformance/guardrail-decision-v1/ots/manifest.json new file mode 100644 index 0000000..04ea9a4 --- /dev/null +++ b/conformance/guardrail-decision-v1/ots/manifest.json @@ -0,0 +1,93 @@ +{ + "schema": "proofpath.ots-anchor-conformance.v0.1", + "cases": [ + { + "name": "verified-bitcoin-attestation", + "returncode": 0, + "expected": { + "status": "TEMPORALLY_ANCHORED", + "reason": "BITCOIN_ATTESTATION_VERIFIED", + "bitcoin_block_height": 358391, + "attested_before": "2015-05-28 CEST" + }, + "output": "Assuming target filename is 'payload'\nSuccess! Bitcoin block 358391 attests existence as of 2015-05-28 CEST\n" + }, + { + "name": "pending-calendar-proof-stays-pending", + "returncode": 1, + "expected": { + "status": "PENDING", + "reason": "BITCOIN_CONFIRMATION_PENDING", + "bitcoin_block_height": null, + "attested_before": null + }, + "output": "Calendar https://alice.btc.calendar.opentimestamps.org: Pending confirmation in Bitcoin blockchain\n" + }, + { + "name": "success-looking-output-with-nonzero-exit-fails-closed", + "returncode": 1, + "expected": { + "status": "INVALID", + "reason": "VERIFIER_EXIT_NONZERO", + "bitcoin_block_height": null, + "attested_before": null + }, + "output": "Success! Bitcoin block 358391 attests existence as of 2015-05-28 CEST\n" + }, + { + "name": "zero-exit-without-bitcoin-attestation-fails-closed", + "returncode": 0, + "expected": { + "status": "INVALID", + "reason": "NO_BITCOIN_ATTESTATION", + "bitcoin_block_height": null, + "attested_before": null + }, + "output": "Timestamp syntax is valid.\n" + }, + { + "name": "unknown-attestation-is-not-bitcoin-proof", + "returncode": 0, + "expected": { + "status": "INVALID", + "reason": "UNKNOWN_ATTESTATION", + "bitcoin_block_height": null, + "attested_before": null + }, + "output": "Unknown Attestation\n" + }, + { + "name": "conflicting-block-attestations-fail-closed", + "returncode": 0, + "expected": { + "status": "INVALID", + "reason": "CONFLICTING_BITCOIN_ATTESTATIONS", + "bitcoin_block_height": null, + "attested_before": null + }, + "output": "Success! Bitcoin block 358391 attests existence as of 2015-05-28 CEST\nSuccess! Bitcoin block 428648 attests existence as of 2016-09-07 CEST\n" + }, + { + "name": "duplicate-attestations-are-ambiguous", + "returncode": 0, + "expected": { + "status": "INVALID", + "reason": "AMBIGUOUS_MULTIPLE_ATTESTATIONS", + "bitcoin_block_height": null, + "attested_before": null + }, + "output": "Success! Bitcoin block 358391 attests existence as of 2015-05-28 CEST\nSuccess! Bitcoin block 358391 attests existence as of 2015-05-28 CEST\n" + }, + { + "name": "pending-and-confirmed-output-is-conflicting", + "returncode": 0, + "expected": { + "status": "INVALID", + "reason": "CONFLICTING_PENDING_AND_CONFIRMED", + "bitcoin_block_height": null, + "attested_before": null + }, + "output": "Pending confirmation in Bitcoin blockchain\nSuccess! Bitcoin block 358391 attests existence as of 2015-05-28 CEST\n" + } + ] +} diff --git a/docs/OTS_TEMPORAL_ANCHOR_PROFILE.md b/docs/OTS_TEMPORAL_ANCHOR_PROFILE.md new file mode 100644 index 0000000..8836713 --- /dev/null +++ b/docs/OTS_TEMPORAL_ANCHOR_PROFILE.md @@ -0,0 +1,108 @@ +# OpenTimestamps temporal-anchor profile + +Status: **experimental sidecar profile** +Schema identifier: `proofpath.temporal-anchor.ots.v0.1` + +## Purpose + +`issued_at` inside a signed or hashed decision is tamper-evident after issuance, +but the issuer can still choose a favorable value before hashing. This sidecar +can establish an external upper bound: + +> the exact subject bytes existed no later than the Bitcoin block attested by +> the verified OpenTimestamps proof. + +OpenTimestamps describes this as proving that data existed prior to a point in +time. The official client validates `.ots` proofs against the Bitcoin +blockchain. + +## Non-goals + +A verified OTS proof does **not** prove: + +- who authored or approved the subject; +- that the subject was correct, authorized, or current; +- the exact creation time; +- that the issuer did not possess different unpublished content; +- freshness of a knowledge source after the anchor. + +It only anchors the exact committed bytes before a Bitcoin block time. + +## Sidecar shape + +```json +{ + "schema": "proofpath.temporal-anchor.ots.v0.1", + "subject": { + "path": "decision.json", + "content_hash": "sha256:..." + }, + "proof": { + "path": "decision.json.ots", + "media_type": "application/vnd.opentimestamps.ots", + "content_hash": "sha256:..." + }, + "verification": { + "status": "TEMPORALLY_ANCHORED", + "reason": "BITCOIN_ATTESTATION_VERIFIED", + "bitcoin_block_height": 358391, + "attested_before": "2015-05-28 CEST" + }, + "temporal_precedence_proven": true, + "verifier": { + "binary": "/usr/local/bin/ots", + "executed": true, + "returncode": 0, + "output_sha256": "sha256:..." + } +} +``` + +## Promotion rule + +A sidecar may set `temporal_precedence_proven: true` only when all of the +following hold: + +1. the exact target bytes were supplied to the verifier; +2. the exact `.ots` proof bytes were supplied; +3. the official verifier process completed successfully; +4. the output contains exactly one Bitcoin block attestation; +5. the output contains no pending or unknown attestation; +6. the verifier result is preserved as evidence. + +A URL, proof filename, claimed block height, self-declared timestamp, or exit +code by itself is insufficient. + +## Runtime trust boundary + +This profile delegates cryptographic validation to the installed official +OpenTimestamps client and its Bitcoin data source. The host, executable, PATH, +Bitcoin node, and captured output remain part of the trusted computing base. + +A hardened deployment should additionally pin the verifier package/binary by +version or digest, record the Bitcoin node configuration class without storing +credentials, and run inside a reproducible environment. + +## Pending proofs + +Fresh timestamps commonly remain pending until calendar data reaches a Bitcoin +block. `PENDING` is not failure and is not proof of temporal precedence. The +proof can later be upgraded and reverified. + +## GuardrailDecision integration + +The base decision should carry only a frozen reference to the sidecar: + +```json +{ + "temporal_anchor": { + "profile": "proofpath.temporal-anchor.ots.v0.1", + "result_ref": "temporal-anchor-result.json", + "result_hash": "sha256:..." + } +} +``` + +Consumers must verify the sidecar hash and require +`verification.status == "TEMPORALLY_ANCHORED"` before using the anchor for +ordering or “what was knowable at decision time.” diff --git a/scripts/check_ots_anchor_vectors.py b/scripts/check_ots_anchor_vectors.py new file mode 100644 index 0000000..9b334af --- /dev/null +++ b/scripts/check_ots_anchor_vectors.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Run deterministic conformance vectors for the OTS output classifier.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import Any, Sequence + + +def load_verifier(repo_root: Path): + script = repo_root / "scripts" / "verify_ots_anchor.py" + spec = importlib.util.spec_from_file_location("proofpath_verify_ots_anchor", script) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load verifier: {script}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 1: + print("usage: check_ots_anchor_vectors.py ", file=sys.stderr) + return 2 + + manifest_path = Path(args[0]).resolve() + repo_root = Path(__file__).resolve().parents[1] + verifier = load_verifier(repo_root) + manifest: dict[str, Any] = json.loads(manifest_path.read_text(encoding="utf-8")) + + failures = 0 + for case in manifest["cases"]: + output = str(case["output"]) + stream = case.get("stream", "stdout") + result = verifier.classify_ots_output( + int(case["returncode"]), + output if stream == "stdout" else "", + output if stream == "stderr" else "", + ) + actual = { + "status": result.status, + "reason": result.reason, + "bitcoin_block_height": result.bitcoin_block_height, + "attested_before": result.attested_before, + } + expected = case["expected"] + + if actual != expected: + failures += 1 + print(f"FAIL {case['name']}") + print(" expected:", json.dumps(expected, sort_keys=True)) + print(" actual: ", json.dumps(actual, sort_keys=True)) + else: + print(f"PASS {case['name']} -> {result.status}/{result.reason}") + + if failures: + print(f"\nOTS temporal-anchor conformance failed: {failures}") + return 1 + + print(f"\nOTS temporal-anchor conformance passed: {len(manifest['cases'])}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_ots_anchor.py b/scripts/verify_ots_anchor.py new file mode 100644 index 0000000..38f7d15 --- /dev/null +++ b/scripts/verify_ots_anchor.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Verify a Bitcoin OpenTimestamps sidecar for exact target bytes. + +This adapter invokes the official ``ots`` CLI. It deliberately fails closed: +a URL, a proof filename, exit code 0, or a success-looking string alone is not +enough. The result is promoted to TEMPORALLY_ANCHORED only when the verifier +exits successfully and reports exactly one Bitcoin block attestation. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Sequence + +SUCCESS_RE = re.compile( + r"Success!\s+Bitcoin block\s+(?P[1-9][0-9]*)\s+" + r"attests existence as of\s+(?P