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.
- strix_verify/__init__.py +206 -0
- strix_verify/client.py +81 -0
- strix_verify/compliance.py +56 -0
- strix_verify/hashing.py +84 -0
- strix_verify/jwks.py +154 -0
- strix_verify/models.py +241 -0
- strix_verify/payload.py +74 -0
- strix_verify/signature.py +97 -0
- strix_verify-0.2.0.dist-info/METADATA +300 -0
- strix_verify-0.2.0.dist-info/RECORD +11 -0
- strix_verify-0.2.0.dist-info/WHEEL +4 -0
strix_verify/__init__.py
ADDED
|
@@ -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
|
+
)
|
strix_verify/hashing.py
ADDED
|
@@ -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
|
strix_verify/payload.py
ADDED
|
@@ -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,,
|