813bd586d6
v0.3 milestone merged to main. Mastery scoring + competency rubrics + verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered. Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres). ---ci--- project: praxis phase: 2 milestone: v0.3 status: complete milestone_complete: true milestone_merged_to_main: true ---/ci---
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02).
|
|
|
|
`GET /vc/verify/<credential_id>` — public, unauthenticated. Fetches the
|
|
credential from SQLite, fetches the 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.
|
|
"""
|
|
|
|
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 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: PraxisStore, credential_id: str
|
|
) -> dict[str, Any] | None:
|
|
row = await store.get_credential(credential_id)
|
|
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)
|
|
try:
|
|
verify_key = await get_public_key_for_verification(store, key_id)
|
|
except KeyError:
|
|
return _invalid(row, secured_doc)
|
|
sig_valid = verify_proof(secured_doc, verify_key)
|
|
revoked = False
|
|
cs = secured_doc.get("credentialStatus") or {}
|
|
idx_str = cs.get("statusListIndex")
|
|
if idx_str is not None:
|
|
sl = BitstringStatusList(store, "default")
|
|
revoked = await sl.get_status(int(idx_str))
|
|
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(),
|
|
}
|
|
|
|
|
|
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"] |