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:
parent
c29bf39b5d
commit
3e537d117f
12 changed files with 604 additions and 34 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
73
docs/evidence/2026-09-28-protocol-hardening/runtime.cdx.json
Normal file
73
docs/evidence/2026-09-28-protocol-hardening/runtime.cdx.json
Normal 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
|
||||||
|
}
|
||||||
127
docs/evidence/2026-09-28-protocol-hardening/verification.json
Normal file
127
docs/evidence/2026-09-28-protocol-hardening/verification.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
29
tests/conftest.py
Normal 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
59
tests/test_attribution.py
Normal 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'}}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
60
tests/test_interval_properties.py
Normal file
60
tests/test_interval_properties.py
Normal 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
83
tests/test_protocol.py
Normal 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
127
tests/test_transport.py
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue