Cover local acceptance gaps and reject nonfinite JSON exponents

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e6f0-a6f6-7b93-9124-55ef584930e2
This commit is contained in:
tegwick 2026-09-28 10:40:02 +02:00
parent c29bf39b5d
commit 3e537d117f
12 changed files with 604 additions and 34 deletions

View file

@ -3,31 +3,31 @@
This maps the current repository tests to the obligations in 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 [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 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. accuracy, production admission or native consumer correctness.
Test names below are in `tests/test_clock.py`, `tests/test_authority.py`, and Test names below are in `tests/test_*.py`; the added protocol, transport,
`tests/test_health.py` unless otherwise noted. Parameterized cases count as tests, attribution and interval sweeps have dedicated files. Parameterized cases count as tests,
not additional acceptance criteria. The release smoke script is separate from not additional acceptance criteria. The release smoke script is separate from
pytest and runs against an installed wheel. pytest and runs against an installed wheel.
| ID | Existing local evidence | Uncovered or native obligation; existing owner record | | 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 | | 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 | | 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`, `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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 ## 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; contract adopted. The 0003 closeout is limited to the reference implementation;
its original task prose is broader than the evidence in several rows above. its original task prose is broader than the evidence in several rows above.
Those unproven native claims remain in existing 0002/0004/0005 records. 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.

View file

@ -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
}

View file

@ -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"
}

View file

@ -42,9 +42,11 @@ installation similarly uses `uv export --locked` and verifies archive hashes.
## Retained evidence ## Retained evidence
The September 28 run is in The current parser-hardening run is in
[evidence/2026-09-28-release/verification.json](evidence/2026-09-28-release/verification.json) [evidence/2026-09-28-protocol-hardening/verification.json](evidence/2026-09-28-protocol-hardening/verification.json)
and its [runtime SBOM](evidence/2026-09-28-release/runtime.cdx.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 The receipt records source-input hashes, lock/build-requirement hashes, verifier
hashes, Python/uv/build-tool versions, artifact digests and smoke checks. Its base hashes, Python/uv/build-tool versions, artifact digests and smoke checks. Its base
commit identifies the checkout before the verification changes were committed; commit identifies the checkout before the verification changes were committed;

View file

@ -1,6 +1,7 @@
"""Strict profile decoding; JWS cryptography is delegated to PyJWT.""" """Strict profile decoding; JWS cryptography is delegated to PyJWT."""
import base64 import base64
import json import json
import math
import re import re
from pathlib import Path from pathlib import Path
import jwt import jwt
@ -43,6 +44,8 @@ def strict_json(raw, limit=8192):
def depth(item, level=0): def depth(item, level=0):
if level > 4: if level > 4:
raise ClockError('JSON nesting too deep') raise ClockError('JSON nesting too deep')
if isinstance(item, float) and not math.isfinite(item):
raise ClockError('nonfinite JSON number')
if isinstance(item, dict): if isinstance(item, dict):
for child in item.values(): depth(child, level + 1) for child in item.values(): depth(child, level + 1)
if isinstance(item, list): if isinstance(item, list):

29
tests/conftest.py Normal file
View file

@ -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

59
tests/test_attribution.py Normal file
View file

@ -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'}}

View file

@ -8,27 +8,6 @@ from railiance_clock.client import Clock, FileTrust
from railiance_clock.core import ClockError, Tick, Window from railiance_clock.core import ClockError, Tick, Window
from railiance_clock.protocol import sign, strict_json, encode64, verify 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): def test_interval_contains_send_with_asymmetric_delay(setup, monkeypatch):
path,key,timer,transport=setup path,key,timer,transport=setup
import time import time

View file

@ -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)

83
tests/test_protocol.py Normal file
View file

@ -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}

127
tests/test_transport.py Normal file
View file

@ -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.

View file

@ -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 platform or consumer proof. Finished status retains only the previously stated
reference boundary; those broader claims are not asserted. Existing reference boundary; those broader claims are not asserted. Existing
0002-T04/0004-T02/T03 and 0005 retain their owner/native gates. 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.