strix-verify 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ """
2
+ strix-verify — Independent verification of Strix governance evidence records.
3
+
4
+ No Strix tooling, SDK, or account required. Uses only standard cryptographic
5
+ primitives (Ed25519, SHA-256) via the Python `cryptography` library.
6
+
7
+ Quick start:
8
+ from strix_verify import verify_evidence
9
+
10
+ result = verify_evidence(
11
+ evidence_id=123,
12
+ proof_base="https://strix.example.com", # your Strix deployment
13
+ jwks_base="https://strixgov.com", # canonical JWKS surface
14
+ )
15
+
16
+ print(result.signature_valid) # SignatureStatus.VERIFIED
17
+ print(result.hash_valid) # True
18
+ print(result.compliance.article12_tamper_resistant) # True
19
+
20
+ Verification flow:
21
+ 1. Fetch evidence record from proof API
22
+ 2. Reconstruct canonical 13-field payload
23
+ 3. Fetch Ed25519 public key from JWKS endpoint
24
+ 4. Verify signature (Ed25519)
25
+ 5. Verify SHA-256 hash
26
+ 6. Derive EU AI Act compliance flags (never asserted)
27
+ 7. Return VerificationResult
28
+
29
+ Two-layer model:
30
+ Layer 1 — Cryptographic Validity (signatureValid):
31
+ "Was this record produced by the holder of the Strix signing key?"
32
+ Layer 2 — Deployment Context (environmentMatch, tenantMatch):
33
+ "Is this record appropriate for this deployment context?"
34
+ SE-14: environment/tenantId read from stored record fields, never process.env.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import Any
40
+
41
+ from .client import EvidenceFetchError, fetch_evidence, fetch_evidence_async
42
+ from .compliance import derive_compliance_flags
43
+ from .hashing import verify_hash
44
+ from .jwks import DEFAULT_JWKS_BASE, JWKSFetchError, KeyNotFoundError, resolve_key
45
+ from .models import CanonicalPayload, SignatureStatus, VerificationResult
46
+ from .payload import build_canonical_payload
47
+ from .signature import verify_signature
48
+
49
+ __version__ = "0.2.0"
50
+ __all__ = [
51
+ "verify_evidence",
52
+ "verify_evidence_record",
53
+ "VerificationResult",
54
+ "SignatureStatus",
55
+ "CanonicalPayload",
56
+ "build_canonical_payload",
57
+ "verify_signature",
58
+ "verify_hash",
59
+ "resolve_key",
60
+ "fetch_evidence",
61
+ "fetch_evidence_async",
62
+ "DEFAULT_JWKS_BASE",
63
+ ]
64
+
65
+
66
+ def verify_evidence(
67
+ evidence_id: str | int,
68
+ *,
69
+ proof_base: str,
70
+ jwks_base: str = DEFAULT_JWKS_BASE,
71
+ extra_keys: list[dict[str, Any]] | None = None,
72
+ expected_environment: str | None = None,
73
+ expected_tenant_id: str | None = None,
74
+ ) -> VerificationResult:
75
+ """
76
+ Fetch and verify a Strix evidence record end-to-end.
77
+
78
+ This is the primary entry point. It fetches the record, verifies the
79
+ signature and hash, derives compliance flags, and returns a VerificationResult.
80
+
81
+ Args:
82
+ evidence_id: The evidence record ID to verify.
83
+ proof_base: Base URL of the Strix proof API for your deployment
84
+ (e.g. "https://strix.example.com"). Required — no default,
85
+ so the package never carries a hardcoded customer URL.
86
+ jwks_base: Base URL of the JWKS endpoint.
87
+ extra_keys: Optional historical JWK dicts for key rotation support.
88
+ expected_environment: If provided, checks Layer 2 environment match.
89
+ (SE-14: read from stored record field, not from process env)
90
+ expected_tenant_id: If provided, checks Layer 2 tenant match.
91
+
92
+ Returns:
93
+ VerificationResult with all verification outcomes.
94
+ """
95
+ result = VerificationResult(evidence_id=evidence_id)
96
+
97
+ try:
98
+ record = fetch_evidence(evidence_id, proof_base=proof_base)
99
+ result.record = record
100
+ except EvidenceFetchError as e:
101
+ result.error = str(e)
102
+ result.signature_valid = SignatureStatus.ERROR
103
+ return result
104
+
105
+ return verify_evidence_record(
106
+ record,
107
+ jwks_base=jwks_base,
108
+ extra_keys=extra_keys,
109
+ expected_environment=expected_environment,
110
+ expected_tenant_id=expected_tenant_id,
111
+ )
112
+
113
+
114
+ def verify_evidence_record(
115
+ record: dict[str, Any],
116
+ *,
117
+ jwks_base: str = DEFAULT_JWKS_BASE,
118
+ extra_keys: list[dict[str, Any]] | None = None,
119
+ expected_environment: str | None = None,
120
+ expected_tenant_id: str | None = None,
121
+ ) -> VerificationResult:
122
+ """
123
+ Verify an already-fetched evidence record dict.
124
+
125
+ Use this when you have the record locally (e.g. from a file or DB).
126
+ Skips the HTTP fetch step.
127
+
128
+ Args:
129
+ record: Raw evidence record dict.
130
+ jwks_base: Base URL of the JWKS endpoint.
131
+ extra_keys: Optional historical JWK dicts.
132
+ expected_environment: Optional Layer 2 environment check.
133
+ expected_tenant_id: Optional Layer 2 tenant check.
134
+
135
+ Returns:
136
+ VerificationResult
137
+ """
138
+ evidence_id = record.get("evidenceId") or record.get("id") or "unknown"
139
+ result = VerificationResult(evidence_id=evidence_id, record=record)
140
+
141
+ # Step 1 — Check signature presence
142
+ signature = record.get("signature")
143
+ signing_key_id = record.get("signingKeyId")
144
+ result.signature_present = bool(signature)
145
+ result.signing_key_id = signing_key_id
146
+
147
+ if not signature or not signing_key_id:
148
+ # Legacy unsigned record
149
+ result.signature_valid = SignatureStatus.LEGACY_UNSIGNED
150
+ # Still verify hash if possible
151
+ canonical = build_canonical_payload(record)
152
+ canonical_json = canonical.to_canonical_json()
153
+ if record.get("evidenceHash"):
154
+ result.hash_valid = verify_hash(canonical_json, record["evidenceHash"])
155
+ result.compliance = derive_compliance_flags(
156
+ result.signature_valid, result.hash_valid,
157
+ result.chain_valid, result.signature_present,
158
+ )
159
+ return result
160
+
161
+ # Step 2 — Fetch public key
162
+ try:
163
+ public_key = resolve_key(signing_key_id, jwks_base=jwks_base, extra_keys=extra_keys)
164
+ except KeyNotFoundError as e:
165
+ result.signature_valid = SignatureStatus.UNVERIFIABLE_KEY
166
+ result.error = str(e)
167
+ result.compliance = derive_compliance_flags(
168
+ result.signature_valid, result.hash_valid,
169
+ result.chain_valid, result.signature_present,
170
+ )
171
+ return result
172
+ except JWKSFetchError as e:
173
+ result.signature_valid = SignatureStatus.ERROR
174
+ result.error = str(e)
175
+ return result
176
+
177
+ # Step 3 — Reconstruct canonical payload
178
+ canonical = build_canonical_payload(record)
179
+ canonical_json = canonical.to_canonical_json()
180
+
181
+ # Step 4 — Verify signature
182
+ sig_ok = verify_signature(canonical_json, signature, public_key)
183
+ result.signature_valid = SignatureStatus.VERIFIED if sig_ok else SignatureStatus.COMPLIANCE_VIOLATION
184
+
185
+ # Step 5 — Verify hash
186
+ if record.get("evidenceHash"):
187
+ result.hash_valid = verify_hash(canonical_json, record["evidenceHash"])
188
+
189
+ # Step 6 — Layer 2: Deployment context checks (SE-14: from stored fields only)
190
+ if expected_environment is not None:
191
+ stored_env = record.get("environment", "")
192
+ result.environment_match = stored_env == expected_environment
193
+
194
+ if expected_tenant_id is not None:
195
+ stored_tenant = record.get("tenantId", "")
196
+ result.tenant_match = stored_tenant == expected_tenant_id
197
+
198
+ # Step 7 — Derive compliance flags (CI-5: derived, never asserted)
199
+ result.compliance = derive_compliance_flags(
200
+ result.signature_valid,
201
+ result.hash_valid,
202
+ result.chain_valid,
203
+ result.signature_present,
204
+ )
205
+
206
+ return result
strix_verify/client.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ HTTP client for fetching evidence records from a Strix proof API.
3
+
4
+ The proof API host is deployment-specific. Callers must pass `proof_base`
5
+ explicitly — there is no default. This keeps the verifier neutral across
6
+ Strix deployments and prevents the package from carrying any single
7
+ customer's URL in its public metadata.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ _TIMEOUT = httpx.Timeout(15.0)
17
+
18
+
19
+ class EvidenceFetchError(Exception):
20
+ """Raised when the evidence record cannot be fetched."""
21
+ pass
22
+
23
+
24
+ def fetch_evidence(
25
+ evidence_id: str | int,
26
+ proof_base: str,
27
+ ) -> dict[str, Any]:
28
+ """
29
+ Fetch an evidence record from a Strix proof API.
30
+
31
+ Args:
32
+ evidence_id: Numeric or string evidence ID.
33
+ proof_base: Base URL of the proof API for your Strix deployment
34
+ (e.g. "https://strix.example.com"). Required — no default.
35
+
36
+ Returns:
37
+ Raw evidence record dict.
38
+
39
+ Raises:
40
+ EvidenceFetchError: If the request fails or returns a non-200 status.
41
+ """
42
+ if not proof_base:
43
+ raise EvidenceFetchError("proof_base is required (no default)")
44
+ url = f"{proof_base.rstrip('/')}/api/proof/{evidence_id}"
45
+ with httpx.Client(timeout=_TIMEOUT) as client:
46
+ response = client.get(url, headers={"Accept": "application/json"})
47
+
48
+ if response.status_code != 200:
49
+ raise EvidenceFetchError(
50
+ f"Proof API fetch failed: HTTP {response.status_code} from {url}"
51
+ )
52
+
53
+ data = response.json()
54
+ # API may wrap the record in a { proof: {...} } envelope
55
+ return data.get("proof") or data
56
+
57
+
58
+ async def fetch_evidence_async(
59
+ evidence_id: str | int,
60
+ proof_base: str,
61
+ ) -> dict[str, Any]:
62
+ """
63
+ Async version of fetch_evidence. Uses httpx.AsyncClient.
64
+
65
+ Args:
66
+ evidence_id: Numeric or string evidence ID.
67
+ proof_base: Base URL of the proof API. Required — no default.
68
+ """
69
+ if not proof_base:
70
+ raise EvidenceFetchError("proof_base is required (no default)")
71
+ url = f"{proof_base.rstrip('/')}/api/proof/{evidence_id}"
72
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
73
+ response = await client.get(url, headers={"Accept": "application/json"})
74
+
75
+ if response.status_code != 200:
76
+ raise EvidenceFetchError(
77
+ f"Proof API fetch failed: HTTP {response.status_code} from {url}"
78
+ )
79
+
80
+ data = response.json()
81
+ return data.get("proof") or data
@@ -0,0 +1,56 @@
1
+ """
2
+ EU AI Act compliance flag derivation.
3
+
4
+ CI-5 invariant: Compliance flags are DERIVED from verification outcomes only.
5
+ They are never asserted directly, never read from stored fields.
6
+
7
+ Article mapping:
8
+ Article 12 — Technical documentation and tamper-resistance
9
+ Article 14 — Human oversight
10
+ Article 28 — Provider obligations (high-risk AI systems)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .models import ComplianceFlags, SignatureStatus
16
+
17
+
18
+ def derive_compliance_flags(
19
+ signature_valid: SignatureStatus,
20
+ hash_valid: bool | None,
21
+ chain_valid: bool | None,
22
+ signature_present: bool,
23
+ ) -> ComplianceFlags:
24
+ """
25
+ Derive EU AI Act compliance flags from verification outcomes.
26
+
27
+ This function implements CI-5: flags are computed, never stored or asserted.
28
+
29
+ Args:
30
+ signature_valid: Layer 1 cryptographic validity status.
31
+ hash_valid: Whether the SHA-256 evidence hash matches. None = unchecked
32
+ (no evidenceHash on record). Treated as False for Article 12.
33
+ chain_valid: Whether the proof chain link is valid (None if unchecked).
34
+ signature_present: Whether a signature field exists on the record.
35
+
36
+ Returns:
37
+ ComplianceFlags with derived boolean values.
38
+ """
39
+ is_verified = signature_valid == SignatureStatus.VERIFIED
40
+ chain_ok = chain_valid is True or chain_valid is None # None = unchecked, not failed
41
+ # hash_valid=None means unchecked (no stored hash), treated as False for Article 12
42
+ hash_ok = hash_valid is True
43
+
44
+ return ComplianceFlags(
45
+ # Article 12: Evidence is tamper-resistant
46
+ # Requires: hash intact AND chain intact AND cryptographically signed by known key
47
+ article12_tamper_resistant=hash_ok and chain_ok and is_verified,
48
+
49
+ # Article 14: Human oversight is cryptographically bound
50
+ # Requires: actor fields are present in a signed record (signature present)
51
+ article14_human_oversight=signature_present,
52
+
53
+ # Article 28: Provider obligations met
54
+ # Requires: record produced by holder of a known Strix signing key
55
+ article28_provider_obligations=is_verified,
56
+ )
@@ -0,0 +1,84 @@
1
+ """
2
+ SHA-256 hash verification for Strix evidence records.
3
+
4
+ Ports the verifyHash() function from packages/strixgov-verifier/src/index.mjs.
5
+
6
+ Hash design (non-circular):
7
+ evidenceHash = sha256(canonical_json_where_evidenceHash="")
8
+
9
+ The evidenceHash field is computed over the canonical payload with the
10
+ evidenceHash field itself set to the empty string. This prevents circular
11
+ dependency (a hash field that contains a hash of itself) while still
12
+ cryptographically binding all other 12 fields into the stored hash.
13
+
14
+ Signing flow:
15
+ 1. Build canonical payload with evidenceHash=""
16
+ 2. compute_evidence_hash(canonical_json) → H
17
+ 3. Update evidenceHash=H on record
18
+ 4. Rebuild canonical payload with evidenceHash=H
19
+ 5. Sign the rebuilt payload (evidenceHash=H is now signature-protected)
20
+
21
+ Verification flow:
22
+ 1. Build canonical payload with stored evidenceHash=H
23
+ 2. Verify signature over canonical payload (covers evidenceHash=H)
24
+ 3. verify_hash(canonical_json, H):
25
+ strips evidenceHash → canonical_json_with_empty
26
+ sha256(canonical_json_with_empty) → H'
27
+ H == H' → True
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import json
34
+
35
+
36
+ def _normalize_for_hashing(canonical_payload: str) -> str:
37
+ """
38
+ Strip the evidenceHash field value (set to "") before computing the hash.
39
+
40
+ This breaks the circular dependency: the stored evidenceHash is the hash
41
+ of the canonical payload with evidenceHash="", not of itself.
42
+
43
+ Field order is preserved because Python 3.7+ dicts maintain insertion order
44
+ and json.loads/json.dumps round-trips are order-preserving.
45
+ """
46
+ data = json.loads(canonical_payload)
47
+ data["evidenceHash"] = ""
48
+ return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
49
+
50
+
51
+ def compute_evidence_hash(canonical_payload: str) -> str:
52
+ """
53
+ Compute the SHA-256 hash of the canonical payload (with evidenceHash normalized to "").
54
+
55
+ Args:
56
+ canonical_payload: The deterministic JSON string from CanonicalPayload.to_canonical_json()
57
+ The evidenceHash field value is stripped before hashing to prevent circular dependency.
58
+
59
+ Returns:
60
+ Lowercase hex-encoded SHA-256 digest.
61
+ """
62
+ normalized = _normalize_for_hashing(canonical_payload)
63
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
64
+
65
+
66
+ def verify_hash(canonical_payload: str, expected_hash: str) -> bool:
67
+ """
68
+ Verify that the canonical payload's SHA-256 hash matches the stored evidence hash.
69
+
70
+ The evidenceHash field in the canonical payload is normalized to "" before
71
+ hashing, matching the signing-time computation.
72
+
73
+ Args:
74
+ canonical_payload: The deterministic JSON string (evidenceHash field is stripped
75
+ before comparison).
76
+ expected_hash: The evidenceHash field from the evidence record (hex string).
77
+
78
+ Returns:
79
+ True if hashes match, False otherwise.
80
+ """
81
+ if not expected_hash:
82
+ return False
83
+ computed = compute_evidence_hash(canonical_payload)
84
+ return computed == expected_hash.lower()
strix_verify/jwks.py ADDED
@@ -0,0 +1,154 @@
1
+ """
2
+ JWKS public key resolution for Strix evidence verification.
3
+
4
+ Ports the fetchPublicKey() function from packages/strixgov-verifier/src/index.mjs.
5
+ Supports key rotation via historical key lookup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
14
+
15
+ from .signature import decode_jwk_to_public_key
16
+
17
+ # Default JWKS endpoint — canonical Strix Platform host. Mirrors the JS
18
+ # verifier's DEFAULT_JWKS_BASE (packages/strixgov-verifier/src/index.mjs).
19
+ # Both /api/proof/* and /.well-known/strix-jwks.json are served here.
20
+ # Historical records signed when the platform was on velarisgroup.app remain
21
+ # verifiable: velarisgroup.app/.well-known/strix-jwks.json 302-redirects to
22
+ # www.strixgov.com during the 90-day cutover window (see
23
+ # docs/DOMAIN_MIGRATION_RUNBOOK.md).
24
+ DEFAULT_JWKS_BASE = "https://www.strixgov.com"
25
+
26
+ # Request timeout
27
+ _TIMEOUT = httpx.Timeout(10.0)
28
+
29
+
30
+ class KeyNotFoundError(Exception):
31
+ """Raised when the signing key cannot be found in the JWKS."""
32
+ pass
33
+
34
+
35
+ class JWKSFetchError(Exception):
36
+ """Raised when the JWKS endpoint cannot be reached or returns an error."""
37
+ pass
38
+
39
+
40
+ def fetch_jwks(jwks_url: str) -> list[dict[str, Any]]:
41
+ """
42
+ Fetch the raw JWKS from the given URL.
43
+
44
+ Args:
45
+ jwks_url: Full URL to the JWKS endpoint (e.g. https://.../.well-known/strix-jwks.json)
46
+
47
+ Returns:
48
+ List of JWK dicts.
49
+
50
+ Raises:
51
+ JWKSFetchError: If the request fails or returns a non-200 status.
52
+ """
53
+ with httpx.Client(timeout=_TIMEOUT) as client:
54
+ response = client.get(jwks_url)
55
+ if response.status_code != 200:
56
+ raise JWKSFetchError(f"JWKS fetch failed: HTTP {response.status_code} from {jwks_url}")
57
+ data = response.json()
58
+ return data.get("keys", [])
59
+
60
+
61
+ def resolve_key(
62
+ kid: str,
63
+ jwks_base: str = DEFAULT_JWKS_BASE,
64
+ extra_keys: list[dict[str, Any]] | None = None,
65
+ ) -> Ed25519PublicKey:
66
+ """
67
+ Resolve an Ed25519PublicKey for the given key ID.
68
+
69
+ Resolution order:
70
+ 1. Fetch from the live JWKS endpoint (with ?kid= filter for efficiency)
71
+ 2. Fall back to extra_keys (historical keys from STRIX_SIGNING_JWKS_EXTRA)
72
+
73
+ JWKS key rotation: Keys are retained for a minimum of 2 years for
74
+ EU AI Act compliance. Historical keys are provided via extra_keys.
75
+
76
+ Args:
77
+ kid: Key ID (format: strix-{env}-{YYYY-MM})
78
+ jwks_base: Base URL of the JWKS server.
79
+ extra_keys: Optional list of historical JWK dicts for key rotation support.
80
+
81
+ Returns:
82
+ Ed25519PublicKey for the given kid.
83
+
84
+ Raises:
85
+ KeyNotFoundError: If the key is not found in JWKS or extra_keys.
86
+ JWKSFetchError: If the JWKS endpoint cannot be reached.
87
+ """
88
+ url = f"{jwks_base.rstrip('/')}/.well-known/strix-jwks.json?kid={kid}"
89
+ try:
90
+ keys = fetch_jwks(url)
91
+ except JWKSFetchError:
92
+ if not extra_keys:
93
+ # No fallback available — propagate so caller gets the real error
94
+ raise
95
+ # Live fetch failed but we have historical keys to try
96
+ keys = []
97
+
98
+ # Find the key in the live JWKS
99
+ matching = [k for k in keys if k.get("kid") == kid]
100
+
101
+ # Fall back to extra_keys (historical rotation keys)
102
+ if not matching and extra_keys:
103
+ matching = [k for k in extra_keys if k.get("kid") == kid]
104
+
105
+ if not matching:
106
+ raise KeyNotFoundError(
107
+ f"Signing key '{kid}' not found in JWKS at {jwks_base} "
108
+ f"or in {len(extra_keys or [])} historical key(s)."
109
+ )
110
+
111
+ return decode_jwk_to_public_key(matching[0])
112
+
113
+
114
+ async def resolve_key_async(
115
+ kid: str,
116
+ jwks_base: str = DEFAULT_JWKS_BASE,
117
+ extra_keys: list[dict[str, Any]] | None = None,
118
+ ) -> Ed25519PublicKey:
119
+ """
120
+ Async version of resolve_key. Uses httpx.AsyncClient.
121
+
122
+ Args:
123
+ kid: Key ID
124
+ jwks_base: Base URL of the JWKS server.
125
+ extra_keys: Optional historical JWK dicts.
126
+
127
+ Returns:
128
+ Ed25519PublicKey
129
+
130
+ Raises:
131
+ KeyNotFoundError, JWKSFetchError
132
+ """
133
+ url = f"{jwks_base.rstrip('/')}/.well-known/strix-jwks.json?kid={kid}"
134
+ keys: list[dict[str, Any]] = []
135
+
136
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
137
+ try:
138
+ response = await client.get(url)
139
+ if response.status_code == 200:
140
+ keys = response.json().get("keys", [])
141
+ except httpx.HTTPError:
142
+ pass # Fall through to extra_keys
143
+
144
+ matching = [k for k in keys if k.get("kid") == kid]
145
+ if not matching and extra_keys:
146
+ matching = [k for k in extra_keys if k.get("kid") == kid]
147
+
148
+ if not matching:
149
+ raise KeyNotFoundError(
150
+ f"Signing key '{kid}' not found in JWKS at {jwks_base} "
151
+ f"or in {len(extra_keys or [])} historical key(s)."
152
+ )
153
+
154
+ return decode_jwk_to_public_key(matching[0])
strix_verify/models.py ADDED
@@ -0,0 +1,241 @@
1
+ """
2
+ Canonical data models for Strix evidence verification.
3
+
4
+ These models are the Python equivalent of the TypeScript types in
5
+ packages/strixgov-verifier and solo-builder-core/src/types.ts.
6
+ Schema version is locked — do not reorder fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ def _coerce_to_number(value: Any, fallback: int) -> int | float:
18
+ """
19
+ Mirror of coerceSchemaVersionToNumber / coerceEvidenceIdToNumber in
20
+ packages/strixgov-verifier/src/index.mjs. Used only for the Academy
21
+ dual-form, where schemaVersion + evidenceId are emitted as JSON numbers.
22
+ Integer-valued inputs serialize without a decimal point (1, not 1.0),
23
+ matching JS Number()/JSON.stringify.
24
+ """
25
+ if isinstance(value, bool):
26
+ return fallback
27
+ if isinstance(value, (int, float)):
28
+ return value
29
+ if isinstance(value, str) and value:
30
+ try:
31
+ f = float(value)
32
+ except ValueError:
33
+ return fallback
34
+ return int(f) if f.is_integer() else f
35
+ return fallback
36
+
37
+
38
+ # ── Verification States ────────────────────────────────────────────────────────
39
+
40
+ class SignatureStatus(str, Enum):
41
+ """Layer 1 — Cryptographic validity of the evidence record's signature."""
42
+
43
+ VERIFIED = "VERIFIED"
44
+ """Signature is valid, payload is intact, key is known."""
45
+
46
+ INVALID_SIGNATURE = "INVALID_SIGNATURE"
47
+ """Record tampered, payload mismatch, or wrong key used."""
48
+
49
+ UNVERIFIABLE_KEY = "UNVERIFIABLE_KEY"
50
+ """Key ID not found in JWKS or rotation history."""
51
+
52
+ UNSIGNED = "UNSIGNED"
53
+ """No signature present on this record."""
54
+
55
+ LEGACY_UNSIGNED = "LEGACY_UNSIGNED"
56
+ """Record predates signing migration (0039) — hash/chain may still be valid."""
57
+
58
+ COMPLIANCE_VIOLATION = "COMPLIANCE_VIOLATION"
59
+ """Record has a signature field but it fails cryptographic verification."""
60
+
61
+ ERROR = "ERROR"
62
+ """Verification could not complete due to a network or parsing error."""
63
+
64
+
65
+ # ── Regulatory Context ─────────────────────────────────────────────────────────
66
+
67
+ class RegulatoryContext(BaseModel):
68
+ """EU AI Act compliance flags — always derived, never asserted."""
69
+
70
+ compliance_mode: str = Field(default="", alias="complianceMode")
71
+ eu_ai_act_article12: bool = Field(default=False, alias="euAiActArticle12")
72
+ eu_ai_act_article14: bool = Field(default=False, alias="euAiActArticle14")
73
+ eu_ai_act_article28: bool = Field(default=False, alias="euAiActArticle28")
74
+
75
+ model_config = {"populate_by_name": True}
76
+
77
+
78
+ # ── Canonical Payload ──────────────────────────────────────────────────────────
79
+
80
+ class CanonicalPayload(BaseModel):
81
+ """
82
+ The locked 13-field signed payload schema (Signed Evidence v1).
83
+
84
+ Field order is frozen — reordering invalidates all existing signatures.
85
+ See: docs/gates/SIGNED-EVIDENCE-V1-AGENT-PROMPT.md
86
+ """
87
+
88
+ schema_version: str = Field(alias="schemaVersion")
89
+ evidence_id: str = Field(alias="evidenceId")
90
+ evidence_hash: str = Field(alias="evidenceHash")
91
+ proof_chain_hash: str = Field(alias="proofChainHash")
92
+ capability_id: str = Field(alias="capabilityId")
93
+ action: str
94
+ actor_id: str = Field(alias="actorId")
95
+ actor_role: str = Field(alias="actorRole")
96
+ created_at: str = Field(alias="createdAt")
97
+ signing_key_id: str = Field(alias="signingKeyId")
98
+ environment: str
99
+ tenant_id: str = Field(alias="tenantId")
100
+ regulatory_context: RegulatoryContext = Field(alias="regulatoryContext")
101
+
102
+ # Dual-form discriminator (v1.3) — mirrors buildCanonicalPayload in
103
+ # packages/strixgov-verifier/src/index.mjs. Academy
104
+ # (sourceApp == "academy-platform") signs schemaVersion + evidenceId as
105
+ # JSON NUMBERS with a euAiActArticle12-first regulatoryContext; Console
106
+ # signers emit them as JSON STRINGS with a complianceMode-first context.
107
+ # The two byte-shapes verify against DIFFERENT signatures. Collapsing this
108
+ # silently breaks every record signed in the dropped form.
109
+ is_academy_form: bool = False
110
+
111
+ # v1.10.0 reconstruction-free path: if the API returns the original signed
112
+ # bytes (signedPayload), they ARE the canonical — return verbatim.
113
+ signed_payload: str | None = None
114
+
115
+ model_config = {"populate_by_name": True}
116
+
117
+ def to_canonical_json(self) -> str:
118
+ """
119
+ Serialize to the canonical JSON string used for signing.
120
+ Field order is frozen. Honors the Academy/Console dual-form
121
+ discriminator and the signedPayload passthrough so this verifier
122
+ reproduces the JS reference byte-for-byte (see the conformance
123
+ fixture at packages/strixgov-verifier/test/fixtures).
124
+ """
125
+ import json
126
+
127
+ # Reconstruction-free path: signed bytes are the canonical bytes.
128
+ if self.signed_payload is not None:
129
+ return self.signed_payload
130
+
131
+ if self.is_academy_form:
132
+ schema_version: int | float | str = _coerce_to_number(self.schema_version, 1)
133
+ evidence_id: int | float | str = _coerce_to_number(self.evidence_id, 0)
134
+ regulatory_context = {
135
+ "euAiActArticle12": self.regulatory_context.eu_ai_act_article12,
136
+ "euAiActArticle14": self.regulatory_context.eu_ai_act_article14,
137
+ "euAiActArticle28": self.regulatory_context.eu_ai_act_article28,
138
+ "complianceMode": self.regulatory_context.compliance_mode,
139
+ }
140
+ else:
141
+ schema_version = self.schema_version
142
+ evidence_id = self.evidence_id
143
+ regulatory_context = {
144
+ "complianceMode": self.regulatory_context.compliance_mode,
145
+ "euAiActArticle12": self.regulatory_context.eu_ai_act_article12,
146
+ "euAiActArticle14": self.regulatory_context.eu_ai_act_article14,
147
+ "euAiActArticle28": self.regulatory_context.eu_ai_act_article28,
148
+ }
149
+
150
+ ordered = {
151
+ "schemaVersion": schema_version,
152
+ "evidenceId": evidence_id,
153
+ "evidenceHash": self.evidence_hash,
154
+ "proofChainHash": self.proof_chain_hash,
155
+ "capabilityId": self.capability_id,
156
+ "action": self.action,
157
+ "actorId": self.actor_id,
158
+ "actorRole": self.actor_role,
159
+ "createdAt": self.created_at,
160
+ "signingKeyId": self.signing_key_id,
161
+ "environment": self.environment,
162
+ "tenantId": self.tenant_id,
163
+ "regulatoryContext": regulatory_context,
164
+ }
165
+ # ensure_ascii=False: JS JSON.stringify emits raw UTF-8, not \\u escapes.
166
+ # Byte-parity on non-ASCII fields depends on this.
167
+ return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
168
+
169
+
170
+ # ── Compliance Flags (derived, never asserted) ─────────────────────────────────
171
+
172
+ class ComplianceFlags(BaseModel):
173
+ """
174
+ EU AI Act compliance flags derived from verification outcomes.
175
+
176
+ CI-5 invariant: These are DERIVED from signatureValid/hashValid/chainValid.
177
+ They must never be asserted directly or read from stored fields.
178
+ """
179
+
180
+ article12_tamper_resistant: bool = False
181
+ """hash_valid AND chain_valid AND signature_valid == VERIFIED"""
182
+
183
+ article14_human_oversight: bool = False
184
+ """signature_present (actor fields cryptographically bound to record)"""
185
+
186
+ article28_provider_obligations: bool = False
187
+ """signature_valid (record produced by holder of known Strix signing key)"""
188
+
189
+
190
+ # ── Verification Result ────────────────────────────────────────────────────────
191
+
192
+ class VerificationResult(BaseModel):
193
+ """
194
+ The complete result of verifying a Strix evidence record.
195
+
196
+ Layer 1 — Cryptographic Validity: signatureValid
197
+ Layer 2 — Deployment Context: environmentMatch, tenantMatch
198
+
199
+ SE-14 invariant: environment and tenantId are read from stored record fields,
200
+ never from process environment variables.
201
+ """
202
+
203
+ evidence_id: str | int
204
+ """The evidence record ID that was verified."""
205
+
206
+ # Layer 1 — Cryptographic validity
207
+ signature_valid: SignatureStatus = SignatureStatus.UNSIGNED
208
+ hash_valid: bool | None = None
209
+ """None = not checked (no evidenceHash on record); True/False = checked."""
210
+ chain_valid: bool | None = None
211
+ """None = not checked (would require fetching the previous record)."""
212
+
213
+ signature_present: bool = False
214
+ signing_key_id: str | None = None
215
+
216
+ # Layer 2 — Deployment context (SE-14: read from stored fields only)
217
+ environment_match: bool | None = None
218
+ tenant_match: bool | None = None
219
+
220
+ # Compliance (derived from Layer 1+2 outcomes — CI-5)
221
+ compliance: ComplianceFlags = Field(default_factory=ComplianceFlags)
222
+
223
+ # The raw evidence record (for caller inspection)
224
+ record: dict[str, Any] | None = None
225
+
226
+ # Error details if verification failed
227
+ error: str | None = None
228
+
229
+ @property
230
+ def verified(self) -> bool:
231
+ """
232
+ True only when cryptographic validity is fully confirmed.
233
+
234
+ hash_valid=None (unchecked) is treated as a pass for the verified property —
235
+ the signature itself covers the evidenceHash field, so absence of an
236
+ external hash check doesn't mean the record is unverified.
237
+ hash_valid=False explicitly means the stored hash failed verification.
238
+ """
239
+ sig_ok = self.signature_valid == SignatureStatus.VERIFIED
240
+ hash_ok = self.hash_valid is not False # None = unchecked = ok; False = failed
241
+ return sig_ok and hash_ok
@@ -0,0 +1,74 @@
1
+ """
2
+ Canonical payload reconstruction for Strix evidence records.
3
+
4
+ Ports the buildCanonicalPayload() function from:
5
+ packages/strixgov-verifier/src/index.mjs
6
+
7
+ The 13-field schema is locked. Reordering fields invalidates all signatures.
8
+ See: docs/gates/SIGNED-EVIDENCE-V1-AGENT-PROMPT.md
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from .models import CanonicalPayload, RegulatoryContext
16
+
17
+
18
+ def build_canonical_payload(record: dict[str, Any]) -> CanonicalPayload:
19
+ """
20
+ Reconstruct the canonical signed payload from an evidence record.
21
+
22
+ Matches the TypeScript implementation exactly:
23
+ - Field extraction uses the same fallback chains
24
+ - The Academy/Console dual-form discriminator (sourceApp ==
25
+ "academy-platform") is honored: serialization differs (see
26
+ CanonicalPayload.to_canonical_json)
27
+ - signedPayload, when present, is returned verbatim (v1.10.0)
28
+ - regulatoryContext defaults to empty/false values if absent
29
+
30
+ Args:
31
+ record: Raw evidence record dict from the proof API or local storage.
32
+
33
+ Returns:
34
+ CanonicalPayload instance. Call .to_canonical_json() to get the
35
+ deterministic JSON string used for signature verification.
36
+ """
37
+ # Dual-form discriminator: Academy signs numbers + article12-first ctx.
38
+ is_academy = record.get("sourceApp") == "academy-platform"
39
+
40
+ # v1.10.0 reconstruction-free path: original signed bytes, if present.
41
+ signed = record.get("signedPayload")
42
+ signed_payload = signed if isinstance(signed, str) and len(signed) > 0 else None
43
+
44
+ # Extract actor sub-fields (handle nested actor object or flat fields)
45
+ actor = record.get("actor") or {}
46
+ actor_id = record.get("actorId") or actor.get("id") or ""
47
+ actor_role = record.get("actorRole") or actor.get("role") or ""
48
+
49
+ # regulatory_context — use stored values or safe defaults
50
+ reg_raw = record.get("regulatoryContext") or {}
51
+ regulatory_context = RegulatoryContext(
52
+ complianceMode=reg_raw.get("complianceMode", ""),
53
+ euAiActArticle12=reg_raw.get("euAiActArticle12", False),
54
+ euAiActArticle14=reg_raw.get("euAiActArticle14", False),
55
+ euAiActArticle28=reg_raw.get("euAiActArticle28", False),
56
+ )
57
+
58
+ return CanonicalPayload(
59
+ schemaVersion="1",
60
+ evidenceId=str(record.get("evidenceId") or record.get("id") or ""),
61
+ evidenceHash=record.get("evidenceHash") or "",
62
+ proofChainHash=record.get("proofChainHash") or "",
63
+ capabilityId=record.get("capabilityId") or "",
64
+ action=record.get("action") or record.get("decision") or "",
65
+ actorId=actor_id,
66
+ actorRole=actor_role,
67
+ createdAt=record.get("createdAt") or "",
68
+ signingKeyId=record.get("signingKeyId") or "",
69
+ environment=record.get("environment") or "",
70
+ tenantId=record.get("tenantId") or "",
71
+ regulatoryContext=regulatory_context,
72
+ is_academy_form=is_academy,
73
+ signed_payload=signed_payload,
74
+ )
@@ -0,0 +1,97 @@
1
+ """
2
+ Ed25519 signature verification for Strix evidence records.
3
+
4
+ Uses the Python `cryptography` library — no Strix tooling required.
5
+ Ports the verifySignature() function from packages/strixgov-verifier/src/index.mjs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+
12
+ from cryptography.exceptions import InvalidSignature
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
14
+
15
+
16
+ def verify_signature(
17
+ canonical_payload: str,
18
+ signature_b64url: str,
19
+ public_key: Ed25519PublicKey,
20
+ ) -> bool:
21
+ """
22
+ Verify an Ed25519 signature against the canonical JSON payload.
23
+
24
+ Args:
25
+ canonical_payload: The deterministic JSON string from CanonicalPayload.to_canonical_json()
26
+ signature_b64url: Base64url-encoded signature from the evidence record
27
+ public_key: Ed25519PublicKey object (from jwks.resolve_key())
28
+
29
+ Returns:
30
+ True if the signature is valid, False otherwise.
31
+ Never raises — invalid signatures return False.
32
+ """
33
+ try:
34
+ data = canonical_payload.encode("utf-8")
35
+ # Add padding if needed for base64url
36
+ padding = 4 - len(signature_b64url) % 4
37
+ if padding != 4:
38
+ signature_b64url += "=" * padding
39
+ sig = base64.urlsafe_b64decode(signature_b64url)
40
+ public_key.verify(sig, data)
41
+ return True
42
+ except (InvalidSignature, ValueError, Exception):
43
+ return False
44
+
45
+
46
+ def decode_jwk_to_public_key(jwk: dict) -> Ed25519PublicKey:
47
+ """
48
+ Convert a JWK (OKP/Ed25519) to an Ed25519PublicKey object.
49
+
50
+ Mirrors the TypeScript SPKI DER construction in fetchPublicKey():
51
+ - Extracts the 'x' parameter (base64url, 32 bytes)
52
+ - Prepends the 12-byte SPKI header: 302a300506032b6570032100
53
+ - Imports as DER SPKI
54
+
55
+ Args:
56
+ jwk: A JWK dict with kty="OKP", crv="Ed25519", and x (base64url pubkey)
57
+
58
+ Returns:
59
+ Ed25519PublicKey
60
+
61
+ Raises:
62
+ ValueError: If the JWK is malformed or the key type is wrong.
63
+ """
64
+ from cryptography.hazmat.primitives.serialization import (
65
+ Encoding,
66
+ PublicFormat,
67
+ load_der_public_key,
68
+ )
69
+
70
+ if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519":
71
+ raise ValueError(
72
+ f"Expected OKP/Ed25519 key, got kty={jwk.get('kty')} crv={jwk.get('crv')}"
73
+ )
74
+
75
+ x_b64url = jwk.get("x", "")
76
+ if not x_b64url:
77
+ raise ValueError("JWK missing 'x' parameter")
78
+
79
+ # base64url decode (add padding if needed)
80
+ padding = 4 - len(x_b64url) % 4
81
+ if padding != 4:
82
+ x_b64url += "=" * padding
83
+ raw_bytes = base64.urlsafe_b64decode(x_b64url)
84
+
85
+ if len(raw_bytes) != 32:
86
+ raise ValueError(f"Ed25519 key must be 32 bytes, got {len(raw_bytes)}")
87
+
88
+ # Build SPKI DER: 12-byte header + 32-byte raw key
89
+ # This mirrors the TypeScript: Buffer.from("302a300506032b6570032100", "hex")
90
+ spki_header = bytes.fromhex("302a300506032b6570032100")
91
+ spki_der = spki_header + raw_bytes
92
+
93
+ key = load_der_public_key(spki_der)
94
+ if not isinstance(key, Ed25519PublicKey):
95
+ raise ValueError("Decoded key is not an Ed25519PublicKey")
96
+
97
+ return key
@@ -0,0 +1,300 @@
1
+ Metadata-Version: 2.4
2
+ Name: strix-verify
3
+ Version: 0.2.0
4
+ Summary: Independent verification of Strix governance evidence records. Ed25519 + SHA-256 only. No Strix tooling or account required. Public source at github.com/strixgov/strix.
5
+ Project-URL: Homepage, https://strixgov.com
6
+ Project-URL: Repository, https://github.com/strixgov/strix
7
+ Project-URL: Documentation, https://strixgov.com/docs/verify
8
+ Author: Strix Platform Team
9
+ License: MIT
10
+ Keywords: ai-safety,ed25519,evidence,governance,proof,strix,verification
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: cryptography>=42.0.0
22
+ Requires-Dist: httpx>=0.27.0
23
+ Requires-Dist: pydantic>=2.0.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # strix-verify
33
+
34
+ Independent verification of Strix governance evidence records.
35
+
36
+ No Strix tooling, SDK, or account required. Uses only standard cryptographic
37
+ primitives (Ed25519, SHA-256) via the Python `cryptography` library.
38
+
39
+ **strix-verify is offline-safe.** Save the JWKS once, ship it with the
40
+ evidence, verify on an air-gapped machine. No callbacks. No telemetry.
41
+ No Strix server required at runtime.
42
+
43
+ **Proof Readiness Level 4.5** — Cryptographically Signed + Externally Verifiable
44
+
45
+ **Public source:** [github.com/strixgov/strix](https://github.com/strixgov/strix)
46
+ **PyPI:** [pypi.org/project/strix-verify](https://pypi.org/project/strix-verify)
47
+
48
+ ---
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install strix-verify
54
+ ```
55
+
56
+ Or from source:
57
+
58
+ ```bash
59
+ cd python/strix-verify
60
+ pip install -e .
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Quick Start
66
+
67
+ ```python
68
+ from strix_verify import verify_evidence
69
+
70
+ result = verify_evidence(
71
+ evidence_id=123,
72
+ proof_base="https://strix.example.com", # your Strix deployment
73
+ jwks_base="https://strixgov.com", # canonical JWKS surface
74
+ )
75
+
76
+ print(result.signature_valid) # SignatureStatus.VERIFIED
77
+ print(result.hash_valid) # True
78
+ print(result.compliance.article12_tamper_resistant) # True
79
+ print(result.compliance.article14_human_oversight) # True
80
+ print(result.compliance.article28_provider_obligations) # True
81
+ ```
82
+
83
+ `proof_base` is required — there is no default. The verifier is neutral
84
+ across Strix deployments and never carries a hardcoded host URL.
85
+
86
+ ### Async
87
+
88
+ ```python
89
+ import asyncio
90
+ from strix_verify import fetch_evidence_async, verify_evidence_record
91
+
92
+ async def main():
93
+ record = await fetch_evidence_async(123, proof_base="https://strix.example.com")
94
+ result = verify_evidence_record(record, jwks_base="https://strixgov.com")
95
+ return result
96
+
97
+ asyncio.run(main())
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Verification Flow
103
+
104
+ 1. **Fetch** — Retrieve evidence record from the Strix proof API
105
+ 2. **Reconstruct** — Build canonical 13-field payload (locked field order)
106
+ 3. **Fetch key** — Resolve Ed25519 public key from JWKS endpoint
107
+ 4. **Verify signature** — Ed25519 signature over canonical payload
108
+ 5. **Verify hash** — SHA-256 hash of canonical payload
109
+ 6. **Derive compliance flags** — EU AI Act flags derived from outcomes (never asserted)
110
+ 7. **Return** — `VerificationResult` with all outcomes
111
+
112
+ ---
113
+
114
+ ## Two-Layer Verification Model
115
+
116
+ ### Layer 1 — Cryptographic Validity (`signature_valid`)
117
+
118
+ > "Was this record produced by the holder of the Strix signing key?"
119
+
120
+ | Status | Meaning |
121
+ |--------|---------|
122
+ | `VERIFIED` | Valid Ed25519 signature from a known key |
123
+ | `LEGACY_UNSIGNED` | Pre-signing record (migration before 0039) |
124
+ | `UNVERIFIABLE_KEY` | Signing key ID not found in JWKS or extra_keys |
125
+ | `COMPLIANCE_VIOLATION` | Signature present but invalid |
126
+ | `ERROR` | Network or unexpected failure |
127
+
128
+ ### Layer 2 — Deployment Context (`environment_match`, `tenant_match`)
129
+
130
+ > "Is this record appropriate for this deployment context?"
131
+
132
+ Optional checks against stored record fields. Per **SE-14**, verification reads
133
+ `environment` and `tenantId` from the stored evidence record — never from
134
+ environment variables. This prevents false failures when a production record is
135
+ verified in a development context.
136
+
137
+ ```python
138
+ result = verify_evidence(
139
+ evidence_id=123,
140
+ expected_environment="production", # checks stored record field
141
+ expected_tenant_id="tenant-abc", # checks stored record field
142
+ )
143
+ print(result.environment_match) # True/False/None
144
+ print(result.tenant_match) # True/False/None
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Verifying a Local Record
150
+
151
+ If you already have the evidence record dict (from a database, file, etc.):
152
+
153
+ ```python
154
+ from strix_verify import verify_evidence_record
155
+
156
+ result = verify_evidence_record(
157
+ record,
158
+ jwks_base="https://strixgov.com",
159
+ )
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Verifying Offline (Air-Gapped)
165
+
166
+ For audit environments that cannot reach the internet, pre-fetch the JWKS
167
+ once, save it next to the evidence, and verify with no network access at
168
+ runtime:
169
+
170
+ ```python
171
+ import json
172
+ from strix_verify import verify_evidence_record
173
+
174
+ with open("evidence-2026-04-19.json") as f:
175
+ record = json.load(f)
176
+
177
+ with open("jwks-snapshot-2026-04.json") as f:
178
+ extra_keys = json.load(f)["keys"]
179
+
180
+ result = verify_evidence_record(
181
+ record,
182
+ extra_keys=extra_keys,
183
+ jwks_base="https://unused.invalid", # never reached when key is in extra_keys
184
+ )
185
+ ```
186
+
187
+ When the signing key is already present in `extra_keys`, `jwks_base` is
188
+ never contacted. The verifier resolves the key from the pre-fetched JWK
189
+ list and does the full cryptographic check locally.
190
+
191
+ ---
192
+
193
+ ## Key Rotation Support
194
+
195
+ Historical signing keys can be provided to verify older records after key rotation:
196
+
197
+ ```python
198
+ import json, os
199
+
200
+ extra_keys = json.loads(os.environ.get("STRIX_SIGNING_JWKS_EXTRA", "[]"))
201
+
202
+ result = verify_evidence(
203
+ evidence_id=123,
204
+ extra_keys=extra_keys,
205
+ )
206
+ ```
207
+
208
+ Key ID format: `strix-{env}-{YYYY-MM}` (e.g., `strix-prod-2026-04`). EU AI Act
209
+ compliance requires a minimum 2-year key retention period.
210
+
211
+ ---
212
+
213
+ ## EU AI Act Compliance Flags
214
+
215
+ Compliance flags are **derived** from verification outcomes — never read from stored
216
+ fields (invariant CI-5). Altering the `regulatoryContext` block in a signed record
217
+ invalidates the Ed25519 signature.
218
+
219
+ | Flag | Derived From |
220
+ |------|-------------|
221
+ | `article12_tamper_resistant` | `hash_valid AND chain_valid AND signature_valid == VERIFIED` |
222
+ | `article14_human_oversight` | `signature_present` (actor fields cryptographically bound) |
223
+ | `article28_provider_obligations` | `signature_valid == VERIFIED` (evidence from known key) |
224
+
225
+ ---
226
+
227
+ ## Canonical Payload Schema
228
+
229
+ The 13-field locked-order payload that is signed and hashed:
230
+
231
+ ```
232
+ schemaVersion (always "1")
233
+ evidenceId
234
+ evidenceHash
235
+ proofChainHash
236
+ capabilityId
237
+ action
238
+ actorId
239
+ actorRole
240
+ createdAt
241
+ signingKeyId
242
+ environment
243
+ tenantId
244
+ regulatoryContext { complianceMode, euAiActArticle12, euAiActArticle14, euAiActArticle28 }
245
+ ```
246
+
247
+ **Warning:** Reordering these fields invalidates all existing signatures. This schema
248
+ is locked and versioned. See `tests/test_payload.py::test_golden_vector` for the
249
+ canonical serialization canary.
250
+
251
+ ---
252
+
253
+ ## JWKS Endpoint
254
+
255
+ Public keys are served at:
256
+
257
+ ```
258
+ GET https://strixgov.com/.well-known/strix-jwks.json
259
+ GET https://strixgov.com/.well-known/strix-jwks.json?kid=strix-prod-2026-04
260
+ ```
261
+
262
+ The endpoint follows RFC 7517. Each key is an OKP/Ed25519 JWK with a `kid` in
263
+ `strix-{env}-{YYYY-MM}` format.
264
+
265
+ ---
266
+
267
+ ## Development
268
+
269
+ ```bash
270
+ # Install with dev dependencies
271
+ pip install -e ".[dev]"
272
+
273
+ # Run tests
274
+ pytest
275
+
276
+ # Run tests with coverage
277
+ pytest --cov=strix_verify --cov-report=term-missing
278
+
279
+ # Lint
280
+ ruff check src/ tests/
281
+
282
+ # Type check
283
+ mypy src/
284
+ ```
285
+
286
+ ---
287
+
288
+ ## Architecture Notes
289
+
290
+ - **No Strix SDK dependency** — pure Python + `cryptography` + `httpx`
291
+ - **Ed25519 SPKI DER construction** matches the TypeScript implementation exactly:
292
+ 12-byte header `302a300506032b6570032100` + 32 raw key bytes
293
+ - **Never raises** on bad signatures — `verify_signature()` returns `False`
294
+ - **Sync and async** HTTP clients available for all network operations
295
+
296
+ ---
297
+
298
+ ## License
299
+
300
+ MIT — see LICENSE.
@@ -0,0 +1,11 @@
1
+ strix_verify/__init__.py,sha256=3qyXdSwRK3i5RNXYOvNoUy6qW3lhbqYBKD1__Z-PMMQ,7409
2
+ strix_verify/client.py,sha256=cWuzfYyJ1-OeDGezko_YOktuSTFvmlDHEEkrjL9qfW4,2462
3
+ strix_verify/compliance.py,sha256=rWSEzYFkhlwarDt2lxotaaMtLGvXsHie9bgMNHnSjNM,2150
4
+ strix_verify/hashing.py,sha256=QpzWbA4Oi71Hu7DMhvxMeBsKxgfve1hduHiKuBUFIvI,3140
5
+ strix_verify/jwks.py,sha256=tO7HvHthlK7XjNpLia4ViS_FXPWPqZbA1y06eZUSGmI,4897
6
+ strix_verify/models.py,sha256=nBgQGulIqBZ3OLWgtDpkV9uO1GY0JxV-IQeMaAKJp-Y,10063
7
+ strix_verify/payload.py,sha256=siD8HICoFIWeJJHXSomInzbszt1BjfSx-R3d_ixkQ88,2993
8
+ strix_verify/signature.py,sha256=1H_HKrXesfMegIv6AutwY5EEqry5r8_zbAYeV7z9fWY,3132
9
+ strix_verify-0.2.0.dist-info/METADATA,sha256=KAL9pxy1OHF-QWoi6CaGIKGqI1U2p2X1lro-uH05Dko,8536
10
+ strix_verify-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ strix_verify-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any