Skip to content

Commit 128746f

Browse files
authored
feat: attestation trust boundary for Verification Context v1.0 (#47) (#54)
* feat: attestation trust boundary for Verification Context v1.0 (#47) Implement the #47 attestation trust boundary, mirroring qwed-verification's architecture (ADR-005 self-attestation stage, fail-closed per its Issue #188 contract): - new qwed_infra/attestation.py: ES256 (ECDSA P-256) JWT attestation service with ephemeral key lifecycle auditing, revocation registry, 8192/4096-byte boundary checks, generic rejection messages, and the never-None AttestationResult fail-closed contract (CRYPTO_UNAVAILABLE -> UNVERIFIABLE; VERIFIED-without-proof or signing failure -> BLOCKED) - enforce_trust_decision() in diagnostics.py: single consumption-side gate validating signature/issuer/expiry/revocation plus claim bindings (status match, query_hash == sha256(formal_statement), proof_hash == diagnostic proof_ref). Any failure -> BLOCKED. Closes the Greptile P1 statement-overclaim vector: a token minted for one statement cannot admit a broader one. - bridge: presence-only attestation policy replaced by full enforcement; VERIFIED + valid token -> ADMIT with Proof.configuration bound to the attested identity (issuer/jti/query_hash), so distinct accepted attestations yield distinct document proof_refs; VERIFIED without token -> UNVERIFIABLE (unchanged); invalid token -> BLOCKED - InfraDiagnosticResult retains proof_data (canonical evidence string) on VERIFIED results so attestations can bind to the exact evidence commitment; mint_diagnostic_attestation() helper issues tokens bound to a diagnostic's own proof_ref - pyproject: add pyjwt + cryptography dependencies Tests: 15 new trust-boundary tests (forged/tampered/expired/revoked tokens -> BLOCKED/DENY; claim binding status/query/proof enforced; statement overclaim rejected; distinct jti -> distinct proof_refs; end-to-end guard attest->enforce->ADMIT flows). Existing fake-token tests updated to mint real bound attestations. 501 tests pass; ruff clean; boundary check passes. * fix: attestation review round - query binding, singleton race, serialization, scanner findings CodeRabbit Critical / CodeAnt+Sentry Critical: _apply_attestation_policy did not forward formal_statement to enforce_trust_decision, so the qwed.query_hash binding check was skipped - a token minted for a narrow statement could admit a broader one through the bridge. Thread formal_statement through as the enforcement query and add a bridge-level regression test (narrow-minted token + broad submitted statement -> BLOCKED/DENY via trust_gate.claims_query_mismatch). QWED Security (codeguard): remove the pre-verification base64 payload decode from verify_attestation - jwt.decode performs payload parsing itself after signature verification, so this layer never decodes attacker-controlled bytes. Size/segment boundary checks retained; RecursionError handler added for deeply-nested payloads inside JWT JSON parsing; explicit isinstance(str) guard rejects non-string tokens. QWED Security (pattern_scan): trust_gate.attestation_verification_error now records error_type (exception class name) instead of str(exc), so no internal detail can leak into developer_fields. CodeAnt Major (race): get_attestation_service() now uses double-checked locking - concurrent first callers can no longer install different ephemeral-key services, which made valid tokens fail verification intermittently. CodeAnt Major (serialization): proof_data is now round-tripped through to_dict()/from_dict() with an integrity check - a serialized pair where sha256(proof_data) != proof_ref raises ValueError, so persisted VERIFIED diagnostics can still mint attestations after reload and tampered pairs fail closed. CodeRabbit nitpicks: removed unused conftest helper/fixture; documented the pyjwt !=2.12.1 exclusion (GHSA-jq35-7prp-9v3f, PyJWK path; PEM keys used here, excluded defensively). 502 tests pass; ruff clean on changed files; boundary check passes. * fix: proof-pair integrity on empty string, from_dict complexity, rebuilt-object field CodeRabbit Major: the from_dict integrity check skipped validation when proof_data was an empty string (truthiness guard), letting a tampered empty proof_data pass with the original proof_ref. The hash comparison now runs whenever both fields are present. SonarCloud: from_dict cognitive complexity 16 > 15 - extracted status parsing (_parse_status) and proof-pair validation (_validated_proof_pair) into module-level helpers; from_dict is now a flat sequence of validated field reads. Sentry LOW: _cast_status_preserving did not initialize the new proof_data attribute when rebuilding malformed diagnostic objects via object.__new__, so a subsequent to_dict() would raise AttributeError. Rebuilt objects now carry getattr(result, 'proof_data', None). 502 tests pass; ruff clean; boundary check passes. * fix: enforce proof commitment on every construction path + drop proof_data from document payloads CodeRabbit Major: _validated_proof_pair only guarded from_dict, so a directly-constructed VERIFIED result could carry altered proof_data while attestation enforcement checked only proof_ref. __post_init__ now fails closed for VERIFIED results unless proof_data is a non-empty string whose sha256 commits to proof_ref (all paths: factory, from_dict, direct). The dataclass is frozen, preventing later divergence. Regression tests cover mismatched/empty/missing proof_data on direct construction. Sentry MEDIUM: to_dict() now includes proof_data, which leaked into VC evidence payloads - every VERIFIED document carried its evidence twice (structured dict + raw canonical JSON string). _build_evidence_payload drops proof_data like proof_ref; it remains an issuance aid on the diagnostic object for mint_diagnostic_attestation. 506 tests pass; ruff clean; boundary check passes. * fix: empty-string proof_data test precision + missing-proof-hash issuance guard and block reason CodeRabbit Minor: test_direct_construction_empty_proof_data_rejected used an all-zero proof_ref, so the pair-integrity mismatch fired before the dedicated non-empty proof_data check. The fixture now uses sha256 of an empty string as proof_ref so the test reaches (and proves) the VERIFIED non-empty proof_data rejection itself. Sentry LOW: a VERIFIED token minted without proof_data carried no qwed.proof_hash claim and was blocked with a generic claims_proof_mismatch reason. Two changes: - create_attestation now raises ValueError at issuance when verified=True without proof_data - unbindable tokens cannot exist. - _validate_attestation_claims distinguishes trust_gate.claims_proof_missing from claims_proof_mismatch for accurate diagnostics. Regressions: missing-proof-hash enforcement yields claims_proof_missing; issuance guard rejects VERIFIED-without-proof_data. 508 tests pass; ruff clean; boundary check passes. * test: extract inputs out of pytest.raises in direct-construction proof_data tests SonarCloud: only the InfraDiagnosticResult constructor invocation remains inside the exception-testing block; status/fields/digest constants are pre-built variables. Applied to all three direct-construction tests (mismatched/empty/missing) since they share the same shape. * fix: mandatory query binding + land missing trust-gate production code (Sentry/CodeAnt/SonarCloud) Sentry HIGH: enforce_trust_decision treated query as fully optional, so an external caller could omit it and skip the qwed.query_hash binding check - reusing one statement's attestation for another whenever status and proof_hash matched. A provided attestation_token now REQUIRES query: omitting it raises ValueError (API misuse, fail-loud) instead of silently downgrading to an unbound check. The bridge path always passes formal_statement; regression test covers token-without-query misuse. Also lands the production half of the CodeRabbit Major round that was inadvertently left out of 49e3e23 (tests shipped, code did not - root cause of the CI failures): - create_attestation rejects status='VERIFIED' with verified=False before signing, and VERIFIED without proof_data at all times. - _validate_attestation_claims distinguishes trust_gate.claims_proof_missing from claims_proof_mismatch, and enforces claims_verified_mismatch so a hand-signed legacy token whose claims do not affirm verification is rejected even with a matching proof_hash. SonarCloud: pytest.raises blocks in the direct-construction and issuance- guard tests now contain a single invocation - inputs are extracted into pre-built variables. 510 tests pass; ruff clean on changed files. * fix: proof_data lifecycle invariants + targeted legacy-payload error (Sentry) Sentry MEDIUM (backward compatibility): deserializing a pre-#47 VERIFIED payload (no proof_data key) now fails with a TARGETED message explaining that pre-attestation payloads cannot be loaded under VC v1.0 attestation rules and the verification must be re-run - instead of the generic non-empty-proof_data error. Deliberately fail-closed: without its canonical evidence the diagnostic can never be attested, so loading it as VERIFIED would violate the trust boundary. Sentry LOW (state consistency): dataclasses.replace() status demotions retained a stale proof_data on non-VERIFIED results. Two changes: - __post_init__ enforces proof_data is None for every non-VERIFIED status (mirrors the existing proof_ref invariant). - the bridge's decision_status override clears proof_data alongside proof_ref when demoting. Regressions: legacy payload load -> ValueError with legacy guidance; BLOCKED constructed with proof_data -> rejected; bridge demotion path produces BLOCKED/DENY cleanly under the new invariant. 513 tests pass; ruff clean; boundary check passes. * refactor: extract __post_init__ invariants into helpers (SonarCloud cognitive complexity) __post_init__ exceeded the allowed cognitive complexity (16 > 15) after the #47 proof_data invariants were added. Split into two single-purpose validators - _validate_verified_invariants (proof_ref presence, audit_trace requirement, evidence commitment, non-empty proof_data) and _validate_non_verified_invariants (null proof_ref/proof_data) - __post_init__ is now a thin dispatcher. All error messages unchanged. 513 tests pass; ruff clean.
1 parent 9a09356 commit 128746f

9 files changed

Lines changed: 1713 additions & 80 deletions

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ dependencies = [
1414
"pydantic>=2.0.0",
1515
"python-hcl2>=4.3.0", # For parsing Terraform
1616
"tomli>=2.0.0; python_version < '3.11'", # For pyproject.toml parsing
17+
"pyjwt>=2.8.0,!=2.12.1", # Attestation signing/verification (ES256); !=2.12.1 excluded for GHSA-jq35-7prp-9v3f (PyJWK path — attestations use PEM keys, excluded defensively)
18+
"cryptography>=41.0.0", # ECDSA P-256 keys for attestations
1719
]
1820

1921
[project.optional-dependencies]

0 commit comments

Comments
 (0)