diff --git a/docs/acceptance-coverage.md b/docs/acceptance-coverage.md index 93ccc06..4db07f7 100644 --- a/docs/acceptance-coverage.md +++ b/docs/acceptance-coverage.md @@ -3,31 +3,31 @@ This maps the current repository tests to the obligations in [acceptance v0.1](../specs/acceptance-v0.1.md). A listed test is evidence for its specific assertion, not completion of the whole criterion. The local suite uses -disposable keys, fake clocks and loopback HTTP; it does not prove independent UTC +disposable keys, fake clocks and loopback HTTP/TLS; it does not prove independent UTC accuracy, production admission or native consumer correctness. -Test names below are in `tests/test_clock.py`, `tests/test_authority.py`, and -`tests/test_health.py` unless otherwise noted. Parameterized cases count as tests, +Test names below are in `tests/test_*.py`; the added protocol, transport, +attribution and interval sweeps have dedicated files. Parameterized cases count as tests, not additional acceptance criteria. The release smoke script is separate from pytest and runs against an installed wheel. | ID | Existing local evidence | Uncovered or native obligation; existing owner record | | --- | --- | --- | | C01 | `test_interval_contains_send_with_asymmetric_delay` changes `time.time` and exercises the injected timer | No complete ±5-minute/zone/wall-step platform matrix; native Windows is unsupported by LinuxTimer. Supported Linux/WSL evidence: 0002-T03, 0004-T02 | -| C02 | `test_interval_contains_send_with_asymmetric_delay`; `test_lifecycle_during_exchange_refuses[delay]` | One deterministic asymmetric-delay case, not property-based interval coverage or measured network guarantees: 0002-T03, 0004-T02 | -| C03 | `test_signed_wrong_binding_refuses`, `test_bad_signature`, `test_rotation_requires_fresh_client_and_matching_signer`; wheel smoke refuses wrong key | No exhaustive version/header/modified-uncertainty matrix; accepted signing/trust profile: 0002-T02 | +| C02 | `test_interval_encloses_true_receipt_across_asymmetry_and_drift` sweeps 324 combinations of outbound/inbound delay, processing, authority error, verification delay and timer drift; delayed exchange refusal | A finite deterministic sweep under explicit error assumptions is not measured network or oscillator evidence: 0002-T03, 0004-T02 | +| C03 | `test_signed_wrong_binding_refuses`, `test_bad_signature`, signed invalid version/number samples, unadmitted and malformed headers, modified uncertainty, signer rotation; wheel smoke refuses wrong key | Finite adversarial cases do not exhaust possible inputs; accepted signing/trust profile: 0002-T02 | | C04 | `test_replay_refused`, delayed-exchange refusal, `test_restart_preserves_floor`, `test_new_client_cannot_reset_expired_trust` | Exact replay across process restart and every nonce-timeout interleaving are not separately tested; native lifecycle evidence: 0004-T02 | | C05 | `test_authority_refuses_unusable_health`, `test_restart_changes_epoch`, `test_step_at_final_timestamp_latches_refusal`, `test_health_expires_during_sample_preparation`, `test_health_model_refusals`, collector replacement test | Disposable host upstream outage/reboot/recovery and accepted error model: 0005-T02/T05, RAIL-HO-WP-0013 | -| C06 | `test_lifecycle_during_exchange_refuses[suspend/domain/backward]`; boot-bound admission behavior | Fake timer disruption does not establish real VM suspend/reboot detection or oscillator bounds: 0002-T03, 0004-T02 | +| C06 | `test_lifecycle_during_exchange_refuses[suspend/domain/backward]`; boot-bound admission; 45 elapsed-time cases spanning drift bounds and timer disagreement | Fake timer disruption does not establish real VM suspend/reboot detection or oscillator bounds: 0002-T03, 0004-T02 | | C07 | `test_validity_uses_entire_interval` checks entirely valid, not-before overlap and exact expiry | Native signed-artifact semantics and protected-side-effect recheck remain consumer-owned: 0002-T04, 0004-T03 | | C08 | Expired-session, delayed-sample and lifecycle refusals; health expiry during preparation | No general offline holdover offered; native loss/age/uncertainty policy evidence: 0004-T02 | -| C09 | Wrong signer and changed/revoked trust refusal; wheel smoke requires explicit disposable loopback admission | No real invalid-date TLS bootstrap or redirect-server integration test in this suite. Transport trust acceptance: 0002-T02, 0005-T03 | +| C09 | Real TLS positive control and expired/not-yet-valid/wrong-host/untrusted certificate refusals; all five redirect statuses refused without following; wrong signer and changed/revoked trust refusal | Disposable CA tests do not prove production bootstrap or trust distribution. Transport trust acceptance: 0002-T02, 0005-T03 | | C10 | No approval/CAS concurrency fixture in this repository | Clock interval checks do not establish atomic consume or check-to-use safety. Consumer native proof: 0002-T04, 0004-T03 | -| C11 | `attribution.py` emits recording-time evidence; no direct attribution test | No historical occurrence translation or calibration-domain proof is implemented here. Audit/consumer contract and native evidence: 0002-T04, 0004-T03 | +| C11 | `test_recording_evidence_preserves_original_event` verifies additive recording-time labels and preservation when merged; disabled, unavailable and expired-clock paths are tested | No historical occurrence translation or calibration-domain proof is implemented here. Audit/consumer contract and native evidence: 0002-T04, 0004-T03 | | C12 | No occurrence-ordering test; application clock returns intervals | Overlap/order handling belongs to the consuming event system; audit acceptance: 0002-T04, 0004-T03 | -| C13 | `test_strict_json`, `test_http_framing_refused`, `test_malformed_health_is_stable_http_refusal`, `test_bad_policy_refuses`, `test_helper_failures_are_sanitized` | Coverage is not exhaustive numeric/schema fuzzing. Profile acceptance remains 0002-T02/T03 | +| C13 | Signed numeric overflow, units/types, malformed headers/envelopes and nonfinite exponent refusal; strict JSON, HTTP framing, malformed health/policy/helper tests | Coverage is not exhaustive numeric/schema fuzzing. Profile acceptance remains 0002-T02/T03 | | C14 | `test_rotation_requires_fresh_client_and_matching_signer`, changed-trust refusal, expired admission and persisted rollback tests | Local file replacement is not distributed revocation; custody visibility deadlines and compromise recovery: 0002-T02, 0005-T03/T05 | -| C15 | `test_connection_limit_and_capacity_recovery`, HTTP liveness/readiness separation, framing and delayed-exchange tests | Eight held connections and recovery are not sustained-load/SLO proof. Host measurements and alerts: 0004-T02, 0005-T06 | +| C15 | `test_connection_limit_and_capacity_recovery`, HTTP liveness/readiness separation, unexpected status/media/encoding refusals, framing and delayed-exchange tests | Eight held connections and recovery are not sustained-load/SLO proof. Host measurements and alerts: 0004-T02, 0005-T06 | | C16 | `tools/wheel_smoke.py` checks installed CLI and signed authority/client HTTP exchange, wrong-key and unhealthy refusal | This is reference integration only. Named native consumer identity/tenant/expiry/CAS migration: 0004-T03 | ## Release and dependency evidence @@ -45,3 +45,19 @@ reproduction procedure. No test or artifact publication makes the proposed contract adopted. The 0003 closeout is limited to the reference implementation; its original task prose is broader than the evidence in several rows above. Those unproven native claims remain in existing 0002/0004/0005 records. + + +## Local gap follow-up + +The expanded suite passes 167 tests and 4 subtests. The two arithmetic sweep +tests additionally exercise 324 signed-exchange scenarios and 45 elapsed-time +scenarios internally; these are not separate pytest counts. The true-time oracle +uses independent simulated transit/processing/verification time and authority +error, with ±500ppm simulated timer drift inside the declared 1000ppm allowance. +This validates containment under that model, not a universal platform drift bound. + +The nonfinite exponent regression exposed a parser gap: Python JSON parsing can +turn `1e999` into infinity without invoking the literal-constant callback. The +parser now rejects nonfinite floats at every nesting level while retaining finite +JSON numbers. All certificates and keys in the transport tests are disposable; +TLS verification remains enabled and each refusal checks its certificate reason. diff --git a/docs/evidence/2026-09-28-protocol-hardening/runtime.cdx.json b/docs/evidence/2026-09-28-protocol-hardening/runtime.cdx.json new file mode 100644 index 0000000..cfb559b --- /dev/null +++ b/docs/evidence/2026-09-28-protocol-hardening/runtime.cdx.json @@ -0,0 +1,73 @@ +{ + "bomFormat": "CycloneDX", + "components": [ + { + "name": "PyJWT", + "purl": "pkg:pypi/pyjwt@2.14.0", + "type": "library", + "version": "2.14.0" + }, + { + "name": "attrs", + "purl": "pkg:pypi/attrs@26.1.0", + "type": "library", + "version": "26.1.0" + }, + { + "name": "cffi", + "purl": "pkg:pypi/cffi@2.1.1", + "type": "library", + "version": "2.1.1" + }, + { + "name": "cryptography", + "purl": "pkg:pypi/cryptography@50.0.1", + "type": "library", + "version": "50.0.1" + }, + { + "name": "jsonschema", + "purl": "pkg:pypi/jsonschema@4.26.0", + "type": "library", + "version": "4.26.0" + }, + { + "name": "jsonschema-specifications", + "purl": "pkg:pypi/jsonschema-specifications@2025.9.1", + "type": "library", + "version": "2025.9.1" + }, + { + "name": "pycparser", + "purl": "pkg:pypi/pycparser@3.0", + "type": "library", + "version": "3.0" + }, + { + "name": "railiance-clock", + "purl": "pkg:pypi/railiance-clock@0.1.0", + "type": "library", + "version": "0.1.0" + }, + { + "name": "referencing", + "purl": "pkg:pypi/referencing@0.37.0", + "type": "library", + "version": "0.37.0" + }, + { + "name": "rpds-py", + "purl": "pkg:pypi/rpds-py@2026.6.3", + "type": "library", + "version": "2026.6.3" + }, + { + "name": "typing_extensions", + "purl": "pkg:pypi/typing-extensions@4.16.0", + "type": "library", + "version": "4.16.0" + } + ], + "specVersion": "1.5", + "version": 1 +} diff --git a/docs/evidence/2026-09-28-protocol-hardening/verification.json b/docs/evidence/2026-09-28-protocol-hardening/verification.json new file mode 100644 index 0000000..5ed77e3 --- /dev/null +++ b/docs/evidence/2026-09-28-protocol-hardening/verification.json @@ -0,0 +1,127 @@ +{ + "build_requirements_sha256": "ff153e73ea3a33c7d33858be50eda03943c5ef5e2706477a90ca76d250878adb", + "build_tools": [ + [ + "hatchling", + "1.32.4" + ], + [ + "packaging", + "26.3" + ], + [ + "pathspec", + "1.1.1" + ], + [ + "pluggy", + "1.6.0" + ], + [ + "tomlkit", + "0.15.1" + ], + [ + "trove-classifiers", + "2026.9.21.13" + ] + ], + "byte_identical": true, + "checkout_build": { + "railiance_clock-0.1.0-py3-none-any.whl": "d6816343fe20007e05d50bf5b8578cdf68e4c12db2f32df6fd71766e40a69fd0", + "railiance_clock-0.1.0.tar.gz": "4465d517f9636ec2243394abdc8e0a3086ad809b81d338be5d0620e0febf90ad" + }, + "independent_builds": [ + { + "railiance_clock-0.1.0-py3-none-any.whl": "d6816343fe20007e05d50bf5b8578cdf68e4c12db2f32df6fd71766e40a69fd0", + "railiance_clock-0.1.0.tar.gz": "4465d517f9636ec2243394abdc8e0a3086ad809b81d338be5d0620e0febf90ad" + }, + { + "railiance_clock-0.1.0-py3-none-any.whl": "d6816343fe20007e05d50bf5b8578cdf68e4c12db2f32df6fd71766e40a69fd0", + "railiance_clock-0.1.0.tar.gz": "4465d517f9636ec2243394abdc8e0a3086ad809b81d338be5d0620e0febf90ad" + } + ], + "limits": "Local Linux build/install evidence; no native UTC, security admission, vulnerability audit or cross-platform reproducibility claim.", + "platform": "Linux/x86_64", + "python": "3.12.3", + "runtime_lock_sha256": "fe4b4ab7fb1740648a13ff87d028914d94e4c793bb5957830e776a878c9adbb9", + "sbom_sha256": "69b45bf19e94e485d08e7af238f2aa3e3fa4f3969d8a552fcf874a12aa1eb07d", + "smoke": { + "checks": [ + "installed_import", + "three_packaged_schemas", + "cli_help", + "signed_http_cli_read", + "wrong_key_refusal", + "unhealthy_cli_refusal" + ], + "distributions": [ + [ + "PyJWT", + "2.14.0" + ], + [ + "attrs", + "26.1.0" + ], + [ + "cffi", + "2.1.1" + ], + [ + "cryptography", + "50.0.1" + ], + [ + "jsonschema", + "4.26.0" + ], + [ + "jsonschema-specifications", + "2025.9.1" + ], + [ + "pycparser", + "3.0" + ], + [ + "railiance-clock", + "0.1.0" + ], + [ + "referencing", + "0.37.0" + ], + [ + "rpds-py", + "2026.6.3" + ], + [ + "typing_extensions", + "4.16.0" + ] + ] + }, + "smoke_script_sha256": "cdb3adb57a68f95d8fd6878195d0ee7dd7a0a00060bacc2a3f95073e6c0b27ec", + "source_base_commit": "c29bf39b5dab1c69d9fa2baf80c974417d7d4e63", + "source_date_epoch": "315532800", + "source_inputs_sha256": { + ".gitignore": "74e4a2bdd348ce27bd64fbafbc09bef61433a821df2f23c726b062ead4896ebf", + "README.md": "c544450bc755e5743bac961fd3832f20ec4d9f86ec3562ca4a2d734a2d694a3e", + "pyproject.toml": "11a3e79fcd7b2929137ebad31647c839edc40572958b08b95f08b0605482416d", + "src/railiance_clock/__init__.py": "7c9c93a7d6bee6c68da5c19055c899d84040ecd5186169441f3a4d24d12c45c4", + "src/railiance_clock/admission.py": "afbea12a31e8ac6bea002a4d0477564d76698b633e88edeb0d2f60c582b5fc47", + "src/railiance_clock/attribution.py": "6cc6a5603295bda8fed47c5b144bdec1c6faff8b27acc440a65b81eb376415bd", + "src/railiance_clock/authority.py": "9a7ba85d17d70aeaad86cbb850b36515b7a82aef085799591bdfdf410cd023ee", + "src/railiance_clock/cli.py": "8ed77836cbdd823b86de849f76f228355bf47fcbfbbc572e22e6e48662929b59", + "src/railiance_clock/client.py": "34401db8cecf76a791013b383f9dc6bd56c0478472fe222f86b2be93505ac00f", + "src/railiance_clock/core.py": "32ac078f8729533dbbee15166cd6e450cd8638d178294304c1c60665d536b9a1", + "src/railiance_clock/health.py": "366e725eaf68d88df3a236d1f71bde5e5b7f24846932041fbaa46182e88d0f07", + "src/railiance_clock/protocol.py": "3aa7755ffc8c03cbf656bc8c82012ba0bf09fc09d9f8647838b6c0f56229f9eb", + "src/railiance_clock/schemas/protected-header-v0.1.schema.json": "15de9f38e61b49677479b431c3ef7e37a674e4b817ef8f78a19bc8fc47bbf4f8", + "src/railiance_clock/schemas/time-request-v0.1.schema.json": "c5a122b1f1f48872a25fffa5950e60dddd585e87a4da502fc4e873a44ae60f4c", + "src/railiance_clock/schemas/time-sample-v0.1.schema.json": "97e026520fff98c7140b8a91b9332ffcba22fd5d509fa983057c7cac6e6aa2d2" + }, + "uv": "uv 0.5.9", + "verifier_sha256": "f3eb2a489ddf32bca164a78c0b3c22358bf45703bcd6d0572413353c1875fb81" +} diff --git a/docs/release-verification.md b/docs/release-verification.md index 308c37a..da9eced 100644 --- a/docs/release-verification.md +++ b/docs/release-verification.md @@ -42,9 +42,11 @@ installation similarly uses `uv export --locked` and verifies archive hashes. ## Retained evidence -The September 28 run is in -[evidence/2026-09-28-release/verification.json](evidence/2026-09-28-release/verification.json) -and its [runtime SBOM](evidence/2026-09-28-release/runtime.cdx.json). +The current parser-hardening run is in +[evidence/2026-09-28-protocol-hardening/verification.json](evidence/2026-09-28-protocol-hardening/verification.json) +and its [runtime SBOM](evidence/2026-09-28-protocol-hardening/runtime.cdx.json). +The earlier [release-verification receipt](evidence/2026-09-28-release/verification.json) +is retained as historical evidence. The receipt records source-input hashes, lock/build-requirement hashes, verifier hashes, Python/uv/build-tool versions, artifact digests and smoke checks. Its base commit identifies the checkout before the verification changes were committed; diff --git a/src/railiance_clock/protocol.py b/src/railiance_clock/protocol.py index 0a6658f..e97f9ea 100644 --- a/src/railiance_clock/protocol.py +++ b/src/railiance_clock/protocol.py @@ -1,6 +1,7 @@ """Strict profile decoding; JWS cryptography is delegated to PyJWT.""" import base64 import json +import math import re from pathlib import Path import jwt @@ -43,6 +44,8 @@ def strict_json(raw, limit=8192): def depth(item, level=0): if level > 4: raise ClockError('JSON nesting too deep') + if isinstance(item, float) and not math.isfinite(item): + raise ClockError('nonfinite JSON number') if isinstance(item, dict): for child in item.values(): depth(child, level + 1) if isinstance(item, list): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..54b5475 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +import json +import os +import pytest +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +from railiance_clock.core import Tick +from railiance_clock.protocol import sign + +class Timer: + def __init__(self): self.n=0; self.boot=0; self.domain='boot1' + def read(self): return Tick(self.n, self.n+self.boot, self.domain) + +@pytest.fixture +def setup(tmp_path): + key=ec.generate_private_key(ec.SECP256R1()) + data={'status':'admitted','authority_id':'railiance01','environment':'test','epoch':'epoch1','policy_id':'test-v1','kid':'test-key', + 'endpoint':'https://clock.invalid/v1/time-samples', + 'public_key_pem':key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo).decode(), + 'limits':{'max_age_ns':5_000_000_000,'max_rtt_ns':2_000_000_000,'max_width_ns':3_000_000_000,'max_server_error_ns':50_000_000,'timer_ppm':1000,'timer_resolution_ns':1000,'suspend_tolerance_ns':5_000_000,'trust_session_ns':60_000_000_000}} + tmp_path.chmod(0o700) + state=tmp_path/'state.json';state.write_text(json.dumps({'authority_id':'railiance01','epoch':'epoch1','lower_ns':0}));state.chmod(0o600) + data.update(client_boot_id='boot1',trust_not_after_boottime_ns=60_000_000_000,rollback_state_file=str(state)) + path=tmp_path/'trust.json';path.write_text(json.dumps(data));path.chmod(0o600) + timer=Timer() + def transport(request): + timer.n+=10_000_000 + return sign(dict(request,epoch='epoch1',policy_id='test-v1',quality='bounded',receive_unix_ns='100000000000',send_unix_ns='100001000000',error_ns='1000000',max_age_ns='5000000000'),key,'test-key') + return path,key,timer,transport + diff --git a/tests/test_attribution.py b/tests/test_attribution.py new file mode 100644 index 0000000..09843d7 --- /dev/null +++ b/tests/test_attribution.py @@ -0,0 +1,59 @@ +from copy import deepcopy + +import pytest + +from railiance_clock import attribution +from railiance_clock.client import Clock, FileTrust +from railiance_clock.core import ClockError + + +def test_disabled_attribution_does_not_construct_clock(monkeypatch): + monkeypatch.delenv('TEST_CLOCK_TRUST', raising=False) + def unexpected(_): + pytest.fail('unconfigured attribution constructed a clock') + monkeypatch.setattr(attribution, '_clock', unexpected) + assert attribution.from_environment('TEST_CLOCK_TRUST') == {} + + +def test_recording_evidence_preserves_original_event(setup, monkeypatch): + path, _, timer, transport = setup + clock = Clock(FileTrust(path), timer=timer, transport=transport) + monkeypatch.setenv('TEST_CLOCK_TRUST', str(path)) + monkeypatch.setattr(attribution, '_clock', lambda _: clock) + event = {'event_id': 'old-event', 'timestamp': '1999-01-01T10:00:00+01:00', + 'clock_domain': 'old-boot', 'sequence': 7, 'predecessors': ['earlier']} + original = deepcopy(event) + evidence = attribution.from_environment('TEST_CLOCK_TRUST') + enriched = {**event, **evidence} + assert event == original + assert all(enriched[k] == v for k, v in original.items()) + assert set(evidence) == {'railiance_time'} + sample = evidence['railiance_time'] + assert sample['kind'] == 'recording_time' and sample['status'] == 'usable' + assert sample['lower_ns'] <= sample['upper_ns'] + assert sample['sample_id'] and sample['authority_id'] == 'railiance01' + assert 'occurrence_time' not in sample + # A wall-clock label is retained as provenance, not recalibrated by this API. + assert enriched['timestamp'] == original['timestamp'] + + +@pytest.mark.parametrize('error', [OSError('secret-path'), ClockError('private reason'), + KeyError('private field'), TypeError('private type')]) +def test_unavailable_attribution_is_sanitized(monkeypatch, error): + monkeypatch.setenv('TEST_CLOCK_TRUST', '/unused') + def unavailable(_): + raise error + monkeypatch.setattr(attribution, '_clock', unavailable) + assert attribution.from_environment('TEST_CLOCK_TRUST') == { + 'railiance_time': {'status': 'unavailable', 'kind': 'recording_time'}} + + +def test_expired_cached_clock_never_returns_old_evidence(setup, monkeypatch): + path, _, timer, transport = setup + clock = Clock(FileTrust(path), timer=timer, transport=transport) + monkeypatch.setenv('TEST_CLOCK_TRUST', str(path)) + monkeypatch.setattr(attribution, '_clock', lambda _: clock) + assert attribution.from_environment('TEST_CLOCK_TRUST')['railiance_time']['status'] == 'usable' + timer.n = 61_000_000_000 + assert attribution.from_environment('TEST_CLOCK_TRUST') == { + 'railiance_time': {'status': 'unavailable', 'kind': 'recording_time'}} diff --git a/tests/test_clock.py b/tests/test_clock.py index 2e702a1..ec4db94 100644 --- a/tests/test_clock.py +++ b/tests/test_clock.py @@ -8,27 +8,6 @@ from railiance_clock.client import Clock, FileTrust from railiance_clock.core import ClockError, Tick, Window from railiance_clock.protocol import sign, strict_json, encode64, verify -class Timer: - def __init__(self): self.n=0; self.boot=0; self.domain='boot1' - def read(self): return Tick(self.n, self.n+self.boot, self.domain) - -@pytest.fixture -def setup(tmp_path): - key=ec.generate_private_key(ec.SECP256R1()) - data={'status':'admitted','authority_id':'railiance01','environment':'test','epoch':'epoch1','policy_id':'test-v1','kid':'test-key', - 'endpoint':'https://clock.invalid/v1/time-samples', - 'public_key_pem':key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo).decode(), - 'limits':{'max_age_ns':5_000_000_000,'max_rtt_ns':2_000_000_000,'max_width_ns':3_000_000_000,'max_server_error_ns':50_000_000,'timer_ppm':1000,'timer_resolution_ns':1000,'suspend_tolerance_ns':5_000_000,'trust_session_ns':60_000_000_000}} - tmp_path.chmod(0o700) - state=tmp_path/'state.json';state.write_text(json.dumps({'authority_id':'railiance01','epoch':'epoch1','lower_ns':0}));state.chmod(0o600) - data.update(client_boot_id='boot1',trust_not_after_boottime_ns=60_000_000_000,rollback_state_file=str(state)) - path=tmp_path/'trust.json';path.write_text(json.dumps(data));path.chmod(0o600) - timer=Timer() - def transport(request): - timer.n+=10_000_000 - return sign(dict(request,epoch='epoch1',policy_id='test-v1',quality='bounded',receive_unix_ns='100000000000',send_unix_ns='100001000000',error_ns='1000000',max_age_ns='5000000000'),key,'test-key') - return path,key,timer,transport - def test_interval_contains_send_with_asymmetric_delay(setup, monkeypatch): path,key,timer,transport=setup import time diff --git a/tests/test_interval_properties.py b/tests/test_interval_properties.py new file mode 100644 index 0000000..00095df --- /dev/null +++ b/tests/test_interval_properties.py @@ -0,0 +1,60 @@ +"""Deterministic bounded-domain sweeps; true-time oracle is independent of UTC arithmetic.""" +from itertools import product +import json + +from railiance_clock.client import Clock, FileTrust +from railiance_clock.core import Tick, elapsed +from railiance_clock.protocol import sign + + +def test_elapsed_encloses_admitted_drift_and_timer_disagreement(): + start = Tick(0, 0, 'boot') + for measured, ppm, disagreement in product( + (0, 1, 999, 1_000_000, 1_000_000_000), (1, 1000, 100000), (0, 1, 1000)): + lo, hi = elapsed(start, Tick(measured, measured + disagreement, 'boot'), + ppm=ppm, resolution_ns=1, suspend_tolerance_ns=1000) + # Assumption being tested: admitted timer error is bounded relative to + # measured elapsed time, plus resolution and observed clock disagreement. + drift = measured * ppm // 1_000_000 + assert lo <= max(0, measured - drift) <= hi + assert lo <= measured + disagreement + drift <= hi + + +def test_interval_encloses_true_receipt_across_asymmetry_and_drift(setup, monkeypatch): + path, key, _, _ = setup + import railiance_clock.client as client + original_verify = client.verify + base = 100_000_000_000 + # 324 cases cover each direction of extreme asymmetry, server offset at + # both edges of its bound, verification delay and admitted timer drift. + for outbound, inbound, processing, offset, verify_delay, drift_sign in product( + (0, 1_000_000, 100_000_000), (0, 1_000_000, 100_000_000), + (0, 10_000_000), (-1_000_000, 0, 1_000_000), (0, 1_000_000, 20_000_000), (-1, 1)): + class Timer: + true = 0 + def read(self): + # 500ppm true-time-relative drift sits within the admitted + # 1000ppm measured-time-relative bound, including rounding. + measured = self.true + drift_sign * (self.true * 500 // 1_000_000) + return Tick(measured, measured, 'boot1') + timer = Timer() + data = json.loads(path.read_text()) + from pathlib import Path + Path(data['rollback_state_file']).write_text(json.dumps( + {'authority_id': 'railiance01', 'epoch': 'epoch1', 'lower_ns': 0})) + def transport(request): + received = base + outbound + offset + sent = received + processing + timer.true = outbound + processing + inbound + return sign(dict(request, epoch='epoch1', policy_id='test-v1', quality='bounded', + receive_unix_ns=str(received), send_unix_ns=str(sent), error_ns='1000000', + max_age_ns='5000000000'), key, 'test-key') + def verify(*args, **kwargs): + value = original_verify(*args, **kwargs) + timer.true += verify_delay + return value + monkeypatch.setattr(client, 'verify', verify) + window = Clock(FileTrust(path), timer=timer, transport=transport).read() + true_now = base + outbound + processing + inbound + verify_delay + assert window.lower_ns <= true_now <= window.upper_ns, ( + outbound, inbound, processing, offset, verify_delay, drift_sign, window) diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..9bfb3c7 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,83 @@ +import json + +import jwt +import pytest + +from railiance_clock.core import ClockError, MAX_NS +from railiance_clock.protocol import TYPE, decode64, encode64, sign, strict_json, verify + + +def payload(setup): + _, key, _, transport = setup + from railiance_clock.protocol import PROFILE + request = {'profile': PROFILE, 'authority_id': 'railiance01', 'environment': 'test', + 'nonce': encode64(bytes(range(32)))} + return verify(transport(request), key.public_key(), 'test-key') + + +def signed_unchecked(value, key, header=None): + return jwt.encode(value, key, algorithm='ES256', + headers=header or {'kid': 'test-key', 'typ': TYPE}).encode() + + +@pytest.mark.parametrize('field,value', [ + ('profile', 'railiance-clock-v9'), ('quality', 'unknown'), ('unexpected', 1), + *[(field, bad) for field in ('receive_unix_ns', 'send_unix_ns', 'error_ns', 'max_age_ns') + for bad in (str(MAX_NS + 1), '-1', '01', '1.0', '1e3', '1ms', '1\n', 1, True, None)], + ('max_age_ns', '0'), ('nonce', encode64(b'short')), +]) +def test_signed_invalid_sample_refused(setup, field, value): + _, key, _, _ = setup + value_dict = payload(setup); value_dict[field] = value + # Bypass our sign() validator: this is an authentic but invalid authority statement. + raw = signed_unchecked(value_dict, key) + with pytest.raises(ClockError): + verify(raw, key.public_key(), 'test-key') + + +@pytest.mark.parametrize('header', [ + {'kid': 'test-key', 'typ': 'wrong'}, {'kid': 'other-key', 'typ': TYPE}, + {'kid': 'test-key', 'typ': TYPE, 'jku': 'https://untrusted.invalid/key'}, + {'kid': 'test-key', 'typ': TYPE, 'crit': ['unknown']}, +]) +def test_authentic_unadmitted_header_refused(setup, header): + _, key, _, _ = setup + with pytest.raises(ClockError): + verify(signed_unchecked(payload(setup), key, header), key.public_key(), 'test-key') + + +@pytest.mark.parametrize('header', [b'null', b'[]', b'{}', b'{', + b'{"alg":"ES256","alg":"ES256","kid":"test-key","typ":"railiance-clock-sample-v0.1"}', + b'{"alg":"none","kid":"test-key","typ":"railiance-clock-sample-v0.1"}']) +def test_malformed_header_refused(setup, header): + _, key, _, _ = setup + raw = sign(payload(setup), key, 'test-key').split(b'.') + raw[0] = encode64(header).encode() + with pytest.raises(ClockError): + verify(b'.'.join(raw), key.public_key(), 'test-key') + + +def test_modified_uncertainty_invalidates_signature(setup): + _, key, _, _ = setup + parts = sign(payload(setup), key, 'test-key').split(b'.') + changed = json.loads(decode64(parts[1].decode())); changed['error_ns'] = '0' + parts[1] = encode64(json.dumps(changed).encode()).encode() + with pytest.raises(ClockError): + verify(b'.'.join(parts), key.public_key(), 'test-key') + + +@pytest.mark.parametrize('raw', [b'{"a":1e999}', b'{"a":[-1e999]}', b'{"a":Infinity}']) +def test_nonfinite_exponent_refused(raw): + with pytest.raises(ClockError): + strict_json(raw) + + +@pytest.mark.parametrize('raw', [b'x' * 8193, b'\xff.abc.def', b'a.b', b'a.b.c.d']) +def test_invalid_envelope_refused(setup, raw): + _, key, _, _ = setup + with pytest.raises(ClockError): + verify(raw, key.public_key(), 'test-key') + + +def test_finite_json_numbers_remain_supported(): + assert strict_json(b'{"a":1.25,"b":1e100}') == {'a': 1.25, 'b': 1e100} diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..4461ec2 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,127 @@ +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ipaddress +import json +import ssl +import threading + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID +import pytest + +from railiance_clock.client import Clock, FileTrust +from railiance_clock.core import ClockError + + +@pytest.fixture +def endpoint(setup): + path, _, timer, transport = setup + servers = [] + def create(*, status=200, media='application/jose', encoding=None, tls=None): + calls = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + def do_GET(self): + calls.append(self.path) + self.send_response(500); self.end_headers() + def do_POST(self): + calls.append(self.path) + body = transport(json.loads(self.rfile.read(int(self.headers['Content-Length'])))) + self.send_response(status) + self.send_header('Content-Type', media) + self.send_header('Content-Length', str(len(body))) + if 300 <= status < 400: + self.send_header('Location', '/must-not-follow') + if encoding: + self.send_header('Content-Encoding', encoding) + self.end_headers(); self.wfile.write(body) + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + if tls: + server.socket = tls.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever); thread.start() + servers.append((server, thread)) + data = json.loads(path.read_text()) + data['endpoint'] = f'{"https" if tls else "http"}://127.0.0.1:{server.server_port}/v1/time-samples' + data['transport'] = 'https' if tls else 'admitted-loopback' + path.write_text(json.dumps(data)) + return Clock(FileTrust(path), timer=timer), calls + yield create + for server, thread in servers: + server.shutdown(); server.server_close(); thread.join() + + +def test_http_response_positive_control(endpoint): + clock, calls = endpoint() + assert clock.read().authority_id == 'railiance01' + assert calls == ['/v1/time-samples'] + + +@pytest.mark.parametrize('status', [301, 302, 303, 307, 308]) +def test_redirect_is_never_followed(endpoint, status): + clock, calls = endpoint(status=status) + with pytest.raises(ClockError): + clock.read() + assert calls == ['/v1/time-samples'] + + +@pytest.mark.parametrize('options', [{'media': 'application/json'}, {'media': 'text/plain'}, + {'encoding': 'gzip'}, {'status': 201}, {'status': 503}]) +def test_unexpected_http_response_refused(endpoint, options): + clock, calls = endpoint(**options) + with pytest.raises(ClockError): + clock.read() + assert calls == ['/v1/time-samples'] + + +@pytest.mark.parametrize('case', ['valid', 'expired', 'future', 'wrong-host', 'untrusted']) +def test_tls_certificate_validation(endpoint, tmp_path, monkeypatch, case): + now = datetime.now(timezone.utc) + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Disposable test CA')]) + ca = (x509.CertificateBuilder().subject_name(ca_name).issuer_name(ca_name) + .public_key(ca_key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=2)).not_valid_after(now + timedelta(days=2)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(ca_key, hashes.SHA256())) + key = ec.generate_private_key(ec.SECP256R1()) + start, end = now - timedelta(hours=1), now + timedelta(hours=1) + if case == 'expired': + start, end = now - timedelta(days=1), now - timedelta(hours=1) + if case == 'future': + start, end = now + timedelta(hours=1), now + timedelta(days=1) + san = x509.DNSName('wrong.invalid') if case == 'wrong-host' else x509.IPAddress(ipaddress.ip_address('127.0.0.1')) + leaf_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Disposable test server')]) + cert = (x509.CertificateBuilder().subject_name(leaf_name).issuer_name(ca_name) + .public_key(key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(start).not_valid_after(end) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.SubjectAlternativeName([san]), critical=False) + .sign(ca_key, hashes.SHA256())) + ca_file, cert_file, key_file = (tmp_path / name for name in ('ca.pem', 'cert.pem', 'key.pem')) + ca_file.write_bytes(ca.public_bytes(serialization.Encoding.PEM)) + cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_file.write_bytes(key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + key_file.chmod(0o600) + empty = tmp_path / 'empty-ca-directory'; empty.mkdir() + # Exercise urllib's real default verification; no permissive SSL context. + monkeypatch.setenv('SSL_CERT_FILE', str(ca_file if case != 'untrusted' else empty / 'missing.pem')) + monkeypatch.setenv('SSL_CERT_DIR', str(empty)) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert_file, key_file) + clock, calls = endpoint(tls=context) + if case == 'valid': + assert clock.read().authority_id == 'railiance01' + assert calls == ['/v1/time-samples'] + else: + with pytest.raises(ClockError) as exc: + clock.read() + reason = exc.value.__cause__.reason + assert isinstance(reason, ssl.SSLCertVerificationError) + expected = {'expired': 'expired', 'future': 'not yet valid', + 'wrong-host': 'IP address mismatch', 'untrusted': 'issuer certificate'} + assert expected[case] in reason.verify_message + assert calls == [] # Refused during TLS, before application data. diff --git a/workplans/RCLK-WP-0003-reference-implementation.md b/workplans/RCLK-WP-0003-reference-implementation.md index b713fda..3e58294 100644 --- a/workplans/RCLK-WP-0003-reference-implementation.md +++ b/workplans/RCLK-WP-0003-reference-implementation.md @@ -76,3 +76,15 @@ concurrency fixture or historical event-translation test, and no complete native platform or consumer proof. Finished status retains only the previously stated reference boundary; those broader claims are not asserted. Existing 0002-T04/0004-T02/T03 and 0005 retain their owner/native gates. + + +### Local acceptance-gap follow-up — 2026-09-28 + +Added attribution recording-label/preservation/failure checks, real loopback TLS +and redirect/response refusal tests, authenticated invalid protocol and numeric +boundary tests, and deterministic interval/drift sweeps. Fixed nonfinite JSON +exponents escaping the literal-constant check. The suite now passes 167 tests +and 4 subtests; see the updated [coverage map](../docs/acceptance-coverage.md). +Recording-time attribution is tested; historical occurrence translation and +consumer CAS/native platform proof remain outside these local claims. Existing +blocked owner plans retain those gates; no work record was created.