diff --git a/README.md b/README.md index 54e8544..a1a53e4 100644 --- a/README.md +++ b/README.md @@ -169,4 +169,4 @@ Both paths use transactional state updates. See [DEPLOYMENT.md](DEPLOYMENT.md) for operational hardening and `compatibility.py` for explicit historical integrity verification. -The current schema is synchronized from `hjs-spec/jep-core` revision `da3fca00be456497f2dbfa045a1bc84aa3ce966d`; its published -07 source remains frozen. +The current structural schema is synchronized with `hjs-spec/jep-core/schemas/jep-event.schema.json`. Its SHA-256 is `d03b607fc7bbb3eb5fda587a21d44fa31a05dd79a8eb40c63ec099785a49801d`. The published -07 source remains frozen; this software patch only aligns existing input checks. diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index a29780b..6bd469c 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,16 @@ +# Software 0.8.5 + +Current Core 0.7 verification now rejects empty extension identifiers and digest +strings with trailing line terminators. The current JWS path validates every +protected-header JSON value for supported finite numbers and well-formed Unicode, +while still verifying the original protected bytes (canonical header JSON is not +required). Explicit historical decoding is unchanged. + +Regression tests use real signatures, both archival and acceptance routes, and a +corrected retry with the same Event Identity. Invalid input must not consume +acceptance state. Existing stored events and accepted identities are not rewritten +or reset. The schema copy matches the current Core structural hardening patch. + # Software 0.8.4 Complete the JCS numeric roundtrip repair: accept the canonical shortest decimal spelling of a binary64 number as well as its exact integer value. For example, JavaScript emits `1000000000000000100` for the float whose exact integer value is `1000000000000000128`. Both serialize to the same JCS bytes. Noncanonical precision-losing integers and overflow remain rejected. diff --git a/VERSION b/VERSION index b60d719..7ada0d3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.4 +0.8.5 diff --git a/jep-event-0.7.schema.json b/jep-event-0.7.schema.json index a7a66c8..e6c7447 100644 --- a/jep-event-0.7.schema.json +++ b/jep-event-0.7.schema.json @@ -56,6 +56,9 @@ "type": "object", "additionalProperties": { "type": "object" + }, + "propertyNames": { + "minLength": 1 } }, "ext_crit": { @@ -82,7 +85,7 @@ "$defs": { "digest": { "type": "string", - "pattern": "^(?:sha256:[0-9a-f]{64}|(?!sha256:)[a-z0-9][a-z0-9-]*:[0-9a-f]+)$" + "pattern": "^(?:sha256:[0-9a-f]{64}|(?!sha256:)[a-z0-9][a-z0-9-]*:[0-9a-f]+)(?![\\s\\S])" }, "eventIdentity": { "type": "object", diff --git a/main.py b/main.py index 5bf1700..633f17b 100644 --- a/main.py +++ b/main.py @@ -162,7 +162,7 @@ def detached_jws_sign(unsigned_event: Dict[str, Any]) -> str: return f"{protected_b64}..{b64u(signature)}" -def detached_jws_verify(event: Dict[str, Any]) -> tuple[bool, Optional[Dict[str, Any]]]: +def detached_jws_verify(event: Dict[str, Any], *, strict_current: bool = False) -> tuple[bool, Optional[Dict[str, Any]]]: sig = event.get("sig") if not isinstance(sig, str) or sig.count(".") != 2: return False, error("ERR_SIGNATURE_CONTAINER_INVALID", "sig is not detached JWS Compact Serialization", 1) @@ -173,6 +173,10 @@ def detached_jws_verify(event: Dict[str, Any]) -> tuple[bool, Optional[Dict[str, try: protected = json.loads(b64u_decode(protected_b64).decode("utf-8"), object_pairs_hook=strict_object, parse_constant=reject_constant) + if strict_current: + # Validate all decoded values, but verify the original header bytes. + # Keep explicitly selected historical parsing behavior unchanged. + jcs_seed(protected) except Exception as exc: return False, error("ERR_SIGNATURE_CONTAINER_INVALID", f"Invalid protected header: {exc}", 1) @@ -485,7 +489,7 @@ def fail(code: str, message: str, check: str, *, indeterminate: bool = False): return fail("ERR_INVALID_TIMESTAMP", "when must be an integer Unix timestamp", "syntax") checks["syntax"] = "pass" - ok, sig_error = detached_jws_verify(event) + ok, sig_error = detached_jws_verify(event, strict_current=True) if not ok: return fail( sig_error["code"], sig_error["message"], "cryptographic", diff --git a/tests/test_input_boundaries_07.py b/tests/test_input_boundaries_07.py new file mode 100644 index 0000000..f6bcb31 --- /dev/null +++ b/tests/test_input_boundaries_07.py @@ -0,0 +1,95 @@ +"""Signed hostile inputs must fail before changing current acceptance state.""" +import json +import uuid + +import pytest +from fastapi.testclient import TestClient + +import main + +client = TestClient(main.app) + + +def new_event(): + response = client.post('/v0.7/events/create', json={ + 'verb': 'J', 'who': 'did:example:boundary-api', 'id': str(uuid.uuid4()), + 'what': {'claim': 'synthetic test'}, + }) + assert response.status_code == 200 + return response.json()['event'] + + +def sign(event, header=None): + kid, signing = main.KEYS.snapshot() + if header is None: + header = json.dumps({'alg': 'Ed25519', 'kid': kid}).encode('utf-8') + protected = main.b64u(header) + unsigned = {k: v for k, v in event.items() if k != 'sig'} + data = (protected + '.' + main.b64u(main.jcs_seed(unsigned))).encode('ascii') + return {**unsigned, 'sig': protected + '..' + main.b64u(signing(data))} + + +def assert_rejected_without_consuming(bad, original, check): + for mode in ('archival', 'acceptance'): + response = client.post('/v0.7/events/verify', json={'event': bad, 'mode': mode}) + assert response.status_code == 200 + result = response.json() + assert result['status'] == 'invalid', result + assert result['checks'][check] == 'fail' + if mode == 'acceptance': + assert result['acceptance'] == {'outcome': 'rejected', 'effect_applied': False} + first = main.validate_event_07(original, mode='acceptance') + retry = main.validate_event_07(original, mode='acceptance') + assert first['acceptance'] == {'outcome': 'accepted', 'effect_applied': True} + assert retry['acceptance'] == {'outcome': 'already_accepted', 'effect_applied': False} + + +@pytest.mark.parametrize('patch', [ + {'ext': {'': {}}}, + {'what': 'sha256:' + 'a' * 64 + '\n'}, + {'ref': 'sha256:' + 'a' * 64 + '\n'}, + {'ref': {'type': 'evidence', 'value': 'demo', 'hash': 'other:ab\n'}}, +]) +def test_schema_rejection_precedes_acceptance(patch): + original = new_event() + assert_rejected_without_consuming(sign({**original, **patch}), original, 'syntax') + + +@pytest.mark.parametrize('suffix', [ + b',"extra":"\\ud800"}', b',"extra":{"\\udfff":true}}', + b',"extra":1e400}', b',"extra":NaN}', + b',"extra":1,"extra":2}', b',"extra":{"x":1,"x":2}}', +]) +def test_current_header_rejection_precedes_acceptance(suffix): + original = new_event() + kid, _ = main.KEYS.snapshot() + header = json.dumps({'alg': 'Ed25519', 'kid': kid}).encode('utf-8') + assert_rejected_without_consuming(sign(original, header[:-1] + suffix), original, 'cryptographic') + + +@pytest.mark.parametrize('encoding', ['utf-16', 'utf-16-le', 'utf-16-be', 'utf-32', 'utf-32-le', 'utf-32-be']) +def test_non_utf8_header_rejected(encoding): + original = new_event() + kid, _ = main.KEYS.snapshot() + raw = json.dumps({'alg': 'Ed25519', 'kid': kid}).encode(encoding) + assert_rejected_without_consuming(sign(original, raw), original, 'cryptographic') + + +def test_noncanonical_but_valid_utf8_header_keeps_original_signature(): + original = new_event() + kid, _ = main.KEYS.snapshot() + raw = json.dumps({'extra': {'text': '合法', 'number': 1e20}, 'kid': kid, 'alg': 'Ed25519'}, + ensure_ascii=False, indent=2).encode('utf-8') + event = sign(original, raw) + result = main.validate_event_07(event) + assert result['status'] == 'valid' + assert result['event_hash'] == main.event_hash(event) + + +def test_explicit_legacy_header_parsing_is_not_reinterpreted(): + original = new_event() + kid, _ = main.KEYS.snapshot() + raw = json.dumps({'alg': 'Ed25519', 'kid': kid}).encode('utf-8')[:-1] + b',"extra":1e400}' + event = sign(original, raw) + assert main.detached_jws_verify(event)[0] is True + assert main.detached_jws_verify(event, strict_current=True)[0] is False