Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.8.4
0.8.5
5 changes: 4 additions & 1 deletion jep-event-0.7.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
"type": "object",
"additionalProperties": {
"type": "object"
},
"propertyNames": {
"minLength": 1
}
},
"ext_crit": {
Expand All @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions tests/test_input_boundaries_07.py
Original file line number Diff line number Diff line change
@@ -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
Loading