c4c20a3722
- TASK-04-01 server/vc/issuer_keys.py: refactor to IssuerKeyStore
Protocol (runtime_checkable). PraxisStore + PgStore both implement it
(R-VC-MIG-03). Functions now accept IssuerKeyStore instead of
PraxisStore. _fetch_private_key_enc rewritten to use
get_public_key_row (protocol method) instead of store._connect()
(PgStore has no _connect). Backward-compatible — all 19 v0.3 VC
tests still pass.
- TASK-04-02 db/pg_store.py: IssuerKeyStore methods (already implemented
in TASK-01-06): init/get_active/get_public_key_row/set_superseded.
get_public_key_row queries by id (not status) → finds superseded keys
(R-VC-MIG-01 fallback). db/store.py get_public_key_row now also
returns private_key_enc (protocol alignment).
- TASK-04-03 server/vc/migrate_keys.py: migrate_issuer_keys() one-time
procedure. R-VC-MIG-01: archives v0.3 public key as superseded BEFORE
generating the fresh v0.4 active key (step 2 before step 3). G-027
first-boot path: no v0.3 active key in SQLite → skip archive, generate
fresh key only. Idempotent (no-op if Postgres already has an active key).
- TASK-04-04 server/vc/verification.py: verify_credential now accepts
pg_store + sqlite_store kwargs. G-011 two-store fallback (binding):
(a) Postgres for key lookup (active + superseded); (b) Postgres for
credential, fall back to SQLite if not found (v0.3 creds stay in
SQLite); (c) SQLite-only if no Postgres (v0.3 compat).
- TASK-04-05 tests/test_vc_migration.py: 9 tests — migration archives +
generates fresh, idempotent, G-027 first-boot, archive-before-active
ordering (R-VC-MIG-01), v0.3 VC verifies against superseded key in
Postgres (R-VC-MIG-01 critical), v0.4 VC verifies, tamper detection,
G-011(b) SQLite fallback, G-011(c) SQLite-only.
---ci---
project: praxis
phase: 1
milestone: v0.4
status: execute
persona: security-engineer
task: 04-01,04-02,04-03,04-04,04-05
requirements:
covered: [REQ-MT-01]
grill:
- G-011 (two-store fallback semantics — explicit in verify_credential)
- G-027 (first-boot: no v0.3 key → skip archive, fresh key only)
risks:
- R-VC-MIG-01 (archived-before-active — tested in test_migration_archives_before_activating_r_vc_mig_01 + test_v03_vc_verifies_against_superseded_key_in_pg)
---/ci---
187 lines
7.0 KiB
Python
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"] |