Files
praxis/server/vc/verification.py
T
Praxis CI 00e39a3f85 feat(milestone): merge phase/01 operator-foundation → milestone/v0.4-operator-tier
Phase 1 complete — Operator Foundation:
- Postgres 16 in Docker-in-LXC (asyncpg pool, 5-table schema, PgStore, migrations)
- Operator auth (argon2id, signed stateless cookies, slowapi 5/min rate limit)
- VC issuer key migration SQLite→Postgres (archive-before-active, R-VC-MIG-01)
- Operator bootstrap CLI (create-operator.py, idempotent)
- Backup cron script + G-008 restore drill
- Graceful degradation (server starts without Postgres)
- 272 tests pass, 33 skip (Postgres-requiring), 0 fail

---ci---
project: praxis
phase: 1
milestone: v0.4
status: complete
requirements:
  covered: [REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02]
  partial: []
---/ci---
2026-08-04 01:41:06 +00:00

187 lines
7.0 KiB
Python

"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02;
v0.4 TASK-04-04 two-store fallback per G-011).
`GET /vc/verify/<credential_id>` — public, unauthenticated. Fetches the
credential + issuer public key, validates the Ed25519 signature against
the JCS-canonicalized payload, checks the Bitstring Status List (no cache
— fetched on every verify call, REQ-NFR-VC-02). Returns JSON
{valid, status, issuer, credential, mastery, credentialTier, verifiedAt}.
No PII beyond what the credential asserts.
G-011 two-store fallback semantics (binding contract):
(a) If Postgres is available (pg_store is not None), use it for issuer
key lookup (both active AND superseded keys — get_public_key_row
queries by id, not status).
(b) If Postgres is available but the credential is not found in its
issued_credentials table, fall back to SQLite issued_credentials
(v0.3 credentials remain in SQLite — D-051 "no re-issuance").
(c) If Postgres is NOT available (pg_store is None), use the existing
v0.3 SQLite path for BOTH keys and credentials (full v0.3 compat).
The key store used for verification is always the one that holds the key
row found by key_id; the credential store is whichever store had the row.
"""
from __future__ import annotations
import datetime as _dt
import json
from typing import Any
from db.store import PraxisStore
from server.vc.issuer import verify_proof, extract_key_id, CREDENTIAL_TIER
from server.vc.issuer_keys import IssuerKeyStore, get_public_key_for_verification
from server.vc.status_list import BitstringStatusList
def _now_iso() -> str:
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
async def verify_credential(
store: IssuerKeyStore,
credential_id: str,
*,
pg_store: IssuerKeyStore | None = None,
sqlite_store: PraxisStore | None = None,
) -> dict[str, Any] | None:
"""Verify a VC. Returns the verification result dict, or None if the
credential id is not found in any store.
Per G-011:
- If pg_store is provided, try it first for BOTH credential + key
lookup; fall back to sqlite_store for the credential if Postgres
doesn't have it (v0.3 credentials stay in SQLite).
- If pg_store is None, use `store` (the v0.3 SQLite path) for both.
"""
row = await _lookup_credential(credential_id, store, pg_store, sqlite_store)
if row is None:
return None
secured_doc = json.loads(row["vc_payload_json"])
key_id = extract_key_id(secured_doc)
if key_id is None:
return _invalid(row, secured_doc)
# Key lookup: prefer pg_store (G-011a) for v0.4 keys + archived v0.3
# keys; fall back to `store` (SQLite) if pg_store doesn't have the key.
verify_key = await _lookup_public_key(key_id, store, pg_store)
if verify_key is None:
return _invalid(row, secured_doc)
sig_valid = verify_proof(secured_doc, verify_key)
revoked = await _check_revocation(secured_doc, store, sqlite_store or store)
status = "revoked" if revoked else "active"
valid = bool(sig_valid and not revoked)
subject = secured_doc.get("credentialSubject") or {}
issuer = secured_doc.get("issuer")
return {
"valid": valid,
"status": status,
"issuer": issuer,
"credential": {
"id": secured_doc.get("id"),
"type": secured_doc.get("type"),
"validFrom": secured_doc.get("validFrom"),
"validUntil": secured_doc.get("validUntil"),
},
"mastery": {
"skill": subject.get("skill"),
"level": subject.get("level"),
"path": subject.get("path"),
"rubricScore": subject.get("rubricScore"),
"scenariosPassed": subject.get("scenariosPassed", []),
"completedWeeks": subject.get("completedWeeks"),
},
"credentialTier": subject.get("credentialTier", CREDENTIAL_TIER),
"verifiedAt": _now_iso(),
}
async def _lookup_credential(
credential_id: str,
store: IssuerKeyStore,
pg_store: IssuerKeyStore | None,
sqlite_store: PraxisStore | None,
) -> dict | None:
"""G-011(b): try Postgres first, fall back to SQLite for v0.3 creds."""
if pg_store is not None:
row = await pg_store.get_credential(credential_id)
if row is not None:
return row
if sqlite_store is not None:
return await sqlite_store.get_credential(credential_id)
return None
# G-011(c): no Postgres — v0.3 SQLite path.
return await store.get_credential(credential_id)
async def _lookup_public_key(
key_id: str,
store: IssuerKeyStore,
pg_store: IssuerKeyStore | None,
):
"""G-011(a): prefer Postgres for key lookup (finds active + superseded);
fall back to `store` (SQLite) if Postgres doesn't have the key."""
if pg_store is not None:
try:
vk = await get_public_key_for_verification(pg_store, key_id)
return vk
except KeyError:
pass
try:
return await get_public_key_for_verification(store, key_id)
except KeyError:
return None
async def _check_revocation(
secured_doc: dict, store: IssuerKeyStore, status_store: PraxisStore
) -> bool:
cs = secured_doc.get("credentialStatus") or {}
idx_str = cs.get("statusListIndex")
if idx_str is None:
return False
sl = BitstringStatusList(status_store, "default")
return await sl.get_status(int(idx_str))
def _invalid(row: dict, secured_doc: dict) -> dict[str, Any]:
subject = secured_doc.get("credentialSubject") or {}
return {
"valid": False,
"status": row.get("status", "active"),
"issuer": secured_doc.get("issuer"),
"credential": {
"id": secured_doc.get("id"),
"type": secured_doc.get("type"),
"validFrom": secured_doc.get("validFrom"),
"validUntil": secured_doc.get("validUntil"),
},
"mastery": {
"skill": subject.get("skill"),
"level": subject.get("level"),
"path": subject.get("path"),
"rubricScore": subject.get("rubricScore"),
"scenariosPassed": subject.get("scenariosPassed", []),
"completedWeeks": subject.get("completedWeeks"),
},
"credentialTier": subject.get("credentialTier", CREDENTIAL_TIER),
"verifiedAt": _now_iso(),
}
async def revoke_credential(store: PraxisStore, credential_id: str) -> bool:
row = await store.get_credential(credential_id)
if row is None:
return False
secured_doc = json.loads(row["vc_payload_json"])
cs = secured_doc.get("credentialStatus") or {}
idx_str = cs.get("statusListIndex")
if idx_str is None:
await store.set_credential_status(credential_id, "revoked")
return True
sl = BitstringStatusList(store, "default")
await sl.set_status(int(idx_str), True)
await store.set_credential_status(credential_id, "revoked")
return True
__all__ = ["verify_credential", "revoke_credential"]