diff --git a/plugin/hermes/server.py b/plugin/hermes/server.py index 25b643c..8614db3 100755 --- a/plugin/hermes/server.py +++ b/plugin/hermes/server.py @@ -1456,6 +1456,10 @@ def tool_jackal_verify_bundle(args: dict[str, Any]) -> dict[str, Any]: if not isinstance(args[key], str) or not args[key]: raise PluginRefusal("plugin-args-schema", f"missing/invalid field: {key!r}") + if not re.fullmatch(r"0|[1-9][0-9]*", args["verification_time_unix"]): + raise PluginRefusal( + "plugin-args-schema", + "verification_time_unix must be a nonnegative integer token") verifier_path, verifier_pin = _claim_component("claim_verifier") inf_path, inf_pin = _claim_component("claim_inference_registry") unit_path, unit_pin = _claim_component("claim_unit_registry") diff --git a/release/claim/SPEC.md b/release/claim/SPEC.md index a7d234e..f0ffe7f 100644 --- a/release/claim/SPEC.md +++ b/release/claim/SPEC.md @@ -122,6 +122,14 @@ decision, display` - `freshness` — `{"source_version","emitted_at_unix","max_age_seconds", "expires_at_unix","nonce","environment_epoch"}` (nullable fields explicit `null`). `environment_epoch` = pinned evaluator SHA-256. + Chronology is bounded on BOTH sides against the caller-supplied + verification time `t`: the verifier refuses `emitted_at_unix < 0` or + `t < 0` (`freshness-schema`), `expires_at_unix < 0` or + `expires_at_unix < emitted_at_unix` (`freshness-schema`), + `t < emitted_at_unix` (`freshness-premature`), `t > expires_at_unix` + when set (`freshness-expired`), and `t - emitted_at_unix > + max_age_seconds` when set (`freshness-stale`). These bind + SELF-DECLARED timestamps for lifecycle consistency only — see §10. - `decision` — `null` or `{"decision_id","action","comparison","threshold", "margin","consequence_class"}` (only `robust_decision` nodes). - `display` — `{"text": ""}` (may be empty string). @@ -366,8 +374,12 @@ result, and the recomputed rendering token + permitted text. verifier binary unavailable), never for semantic failure. Replay honesty: the verifier proves nonce/epoch binding and caller-time -freshness only. One-time replay PREVENTION requires a durable external -nonce store and is an explicit residual non-claim in v1. +freshness only. Freshness bounds emission, verification, and expiry +against the caller-supplied time (see §3), but those timestamps are +SELF-DECLARED by the producer: the verifier enforces internal lifecycle +consistency, NOT timestamp authenticity or temporal provenance (there is +no trusted time source). One-time replay PREVENTION requires a durable +external nonce store and is an explicit residual non-claim in v1. ## 11. Renderer diff --git a/release/evidence/ci_claim_fixture_v160/pins.json b/release/evidence/ci_claim_fixture_v160/pins.json index 1787869..2ee8f39 100644 --- a/release/evidence/ci_claim_fixture_v160/pins.json +++ b/release/evidence/ci_claim_fixture_v160/pins.json @@ -9,5 +9,5 @@ "note": "caller-side semantic + infrastructure pins recorded at fixture creation; the recorded environment epoch is the sealed macOS arm64 jackal-native identity whose exact-cert output this bundle embeds - CI replays the recorded epoch, it does not rebuild the engine", "root": "767334ad2c9340c78573e19c9b31b3968054eb08440a31b8f6621a6f29dcf8da", "schema": "jackal-ci-claim-admission-pins-v1", - "verification_time_unix": 1786752000 + "verification_time_unix": 1786924800 } diff --git a/release/evidence/claim_hostile_matrix_v160.json b/release/evidence/claim_hostile_matrix_v160.json index db33e77..dd2a276 100644 --- a/release/evidence/claim_hostile_matrix_v160.json +++ b/release/evidence/claim_hostile_matrix_v160.json @@ -457,6 +457,60 @@ "observed": "verified/", "ok": true }, + { + "expect": "refused/freshness-premature", + "id": "F-premature-future-emit", + "observed": "refused/freshness-premature", + "ok": true + }, + { + "expect": "refused/freshness-schema", + "id": "F-negative-vtime", + "observed": "refused/freshness-schema", + "ok": true + }, + { + "expect": "refused/freshness-schema", + "id": "F-expires-before-emit", + "observed": "refused/freshness-schema", + "ok": true + }, + { + "expect": "refused/freshness-schema", + "id": "F-expires-negative", + "observed": "refused/freshness-schema", + "ok": true + }, + { + "expect": "verified", + "id": "F-emit-eq-vtime-ok", + "observed": "verified/", + "ok": true + }, + { + "expect": "verified", + "id": "F-expiry-eq-emit-eq-vtime-ok", + "observed": "verified/", + "ok": true + }, + { + "expect": "verified", + "id": "F-aba-a1", + "observed": "verified/", + "ok": true + }, + { + "expect": "refused/freshness-premature", + "id": "F-aba-b", + "observed": "refused/freshness-premature", + "ok": true + }, + { + "expect": "verified", + "id": "F-aba-a2", + "observed": "verified/", + "ok": true + }, { "expect": "verified", "id": "F-pos-nonce", @@ -652,5 +706,5 @@ ], "schema": "jackal-claim-hostile-matrix-v1", "verdict": "PASS", - "verifier": "e0fcb9540c730bd9bb492b528ed42d29d49fc775b3aa0f9b831b6264fd68fd22" + "verifier": "90d6480e7353730488c88e1848972c837dc0e443e9a8e358327843e7ac13afc2" } diff --git a/tests/claim_hostile_test.py b/tests/claim_hostile_test.py index 95d59b8..293d0b1 100755 --- a/tests/claim_hostile_test.py +++ b/tests/claim_hostile_test.py @@ -1046,6 +1046,7 @@ def family_freshness() -> None: bundle_of([n], n["id"]), "environment-mismatch") n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME - 1000) n["freshness"]["expires_at_unix"] = str(VTIME - 10) rehash(n) expect_refused("F-expired", @@ -1064,6 +1065,65 @@ def family_freshness() -> None: rehash(n) expect_verified("F-pos-age-ok", bundle_of([n], n["id"])) + # ---- chronology lower bounds (SIGNOFF proposal): the verifier must + # reject verification-before-emission, negative verification time, + # and contradictory emitted/expires lifecycles -- not only upper + # age/expiry bounds. RED on pre-fix bytes: the false-accept rows + # below VERIFY (F-expires-negative pre-fix refused under the wrong + # class) because only one-sided bounds were enforced. + n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME + 100) + rehash(n) + expect_refused("F-premature-future-emit", + bundle_of([n], n["id"]), "freshness-premature") + + n = input_node("x", "1", "2") + rehash(n) + expect_refused("F-negative-vtime", + bundle_of([n], n["id"]), "freshness-schema", vtime=-1) + + n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME) + n["freshness"]["expires_at_unix"] = str(VTIME - 100) + rehash(n) + expect_refused("F-expires-before-emit", + bundle_of([n], n["id"]), "freshness-schema", + vtime=VTIME - 100) + + n = input_node("x", "1", "2") + n["freshness"]["expires_at_unix"] = "-5" + rehash(n) + expect_refused("F-expires-negative", + bundle_of([n], n["id"]), "freshness-schema") + + n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME) + rehash(n) + expect_verified("F-emit-eq-vtime-ok", bundle_of([n], n["id"]), + vtime=VTIME) + + n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME) + n["freshness"]["expires_at_unix"] = str(VTIME) + rehash(n) + expect_verified("F-expiry-eq-emit-eq-vtime-ok", + bundle_of([n], n["id"]), vtime=VTIME) + + # A -> B -> A restoration on the chronology gate. + n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME) + rehash(n) + expect_verified("F-aba-a1", bundle_of([n], n["id"]), vtime=VTIME) + n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME + 100) + rehash(n) + expect_refused("F-aba-b", bundle_of([n], n["id"]), + "freshness-premature", vtime=VTIME) + n = input_node("x", "1", "2") + n["freshness"]["emitted_at_unix"] = str(VTIME) + rehash(n) + expect_verified("F-aba-a2", bundle_of([n], n["id"]), vtime=VTIME) + n = input_node("x", "1", "2") n["freshness"]["nonce"] = "abc123" rehash(n) diff --git a/tools/claim_bundle_verify.py b/tools/claim_bundle_verify.py index 94d579a..b43f203 100755 --- a/tools/claim_bundle_verify.py +++ b/tools/claim_bundle_verify.py @@ -204,7 +204,8 @@ "environment-mismatch", "epoch-mismatch", "evidence-encoding", "evidence-hash-mismatch", "evidence-kind", "evidence-producer-untrusted", "evidence-prop-mismatch", "evidence-verify-failed", "float-forbidden", - "freshness-expired", "freshness-schema", "freshness-stale", + "freshness-expired", "freshness-premature", "freshness-schema", + "freshness-stale", "graph-cycle", "implementation-upgrade", "int-budget", "interval-div-zero", "interval-endpoints", "legacy-dispatch-failed", "legacy-verifier-unavailable", "machine-cert-invalid", @@ -2156,8 +2157,15 @@ def deep_validate_node(node: dict, units: UnitRegistry, args, or max_age < 0): raise Refusal("freshness-schema", "max_age_seconds") expires = fresh["expires_at_unix"] + expires_val = None if expires is not None: - parse_int_token(expires, what="expires_at") + expires_val = parse_int_token(expires, what="expires_at") + if expires_val < 0: + raise Refusal("freshness-schema", "expires_at negative") + if expires_val < emitted: + raise Refusal("freshness-schema", + f"expires_at {expires_val} precedes emitted_at " + f"{emitted}") nonce = fresh["nonce"] if nonce is not None and not isinstance(nonce, str): raise Refusal("freshness-schema", "nonce") @@ -2165,9 +2173,22 @@ def deep_validate_node(node: dict, units: UnitRegistry, args, raise Refusal("environment-mismatch", f"node environment {str(fresh['environment_epoch'])[:16]}") vtime = args.verification_time_unix - if expires is not None and vtime > int(expires): + # Chronology lower bounds: a well-formed bundle must not verify before + # it was emitted, at a negative (pre-epoch) verification time, or with a + # lifecycle whose expiry precedes emission. Ordering matters: domain + # violations refuse as freshness-schema; a valid-but-too-early check + # refuses as freshness-premature; only then are the upper expiry/age + # bounds evaluated (so vtime - emitted is always non-negative below). + if vtime < 0: + raise Refusal("freshness-schema", + f"verification time {vtime} is negative") + if vtime < emitted: + raise Refusal("freshness-premature", + f"verification time {vtime} precedes emitted_at " + f"{emitted}") + if expires_val is not None and vtime > expires_val: raise Refusal("freshness-expired", - f"expired at {expires}, verification time {vtime}") + f"expired at {expires_val}, verification time {vtime}") if max_age is not None and vtime - emitted > max_age: raise Refusal("freshness-stale", f"age {vtime - emitted}s > max {max_age}s") @@ -2367,6 +2388,8 @@ def enforce_policy(policy: dict, bundle: dict, root: dict, by_id: dict, if require["require_nonce"] and root["freshness"]["nonce"] is None: raise Refusal("policy-violation", "nonce required by policy") if require["max_age_seconds"] is not None: + # deep_validate_node has already enforced vtime >= emitted for every + # node (freshness-premature), so this policy age is non-negative. emitted = int(root["freshness"]["emitted_at_unix"]) if args.verification_time_unix - emitted > \ require["max_age_seconds"]: