feat(milestone): merge phase/01 mastery-core → milestone/v0.3-mastery-scoring

Phase 1 complete. Mastery scoring + competency rubrics + VC issuer shipped.
9 slices, 5 waves, 238 tests passing, 13/13 REQ-IDs covered.
4/4 grill MUST conditions satisfied. VERIFY: APPROVE_WITH_NOTES.

---ci---
project: praxis
phase: 1
milestone: v0.3
status: complete
requirements:
  covered: [REQ-MAST-01, REQ-MAST-02, REQ-MAST-03, REQ-SCEN-02, REQ-SCEN-03, REQ-SCEN-04, REQ-PATH-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02, REQ-NFR-VC-01, REQ-NFR-VC-02, REQ-NFR-IRT-01]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 00:03:13 +00:00
parent 926322960e
commit 4d39596a7d
51 changed files with 6962 additions and 215 deletions
View File
+214
View File
@@ -0,0 +1,214 @@
"""W3C VC 2.0 issuance — Ed25519 + JCS + eddsa-jcs-2022 proof (SLICE-09 TASK-09-02).
Builds a Verifiable Credential per VC-DM 2.0, secures it with a Data Integrity
`eddsa-jcs-2022` proof (JCS canonicalization, Ed25519 signature), and persists
it to SQLite. The `issue_credential` coroutine is the entry point wired into
SessionRecorder.run_mastery_flow (grill Axis 8 MUST).
Credential tier is `formative` (grill Axis 4 MUST #1) — the v0.3 credential is
a formative mastery signal, not a high-stakes summative credential.
"""
from __future__ import annotations
import base64
import datetime as _dt
import hashlib
import json
import os
import uuid
from typing import Any
import canonicaljson
import nacl.signing
from db.store import PraxisStore
from server.vc.issuer_keys import KeyPair, get_active_signing_key
from server.vc.status_list import BitstringStatusList
ISSUER_URL_DEFAULT = "https://praxis.example/issuers/v0.3"
CONTEXTS = [
"https://www.w3.org/ns/credentials/v2",
"https://praxis.example/contexts/mastery/v1",
]
CREDENTIAL_TIER = "formative"
def _issuer_url() -> str:
return os.environ.get("PRAXIS_ISSUER_URL", ISSUER_URL_DEFAULT).rstrip("/")
def _now_iso() -> str:
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _valid_until(issuance_iso: str, years: int = 3) -> str:
dt = _dt.datetime.strptime(issuance_iso, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=_dt.timezone.utc
)
return (dt + _dt.timedelta(days=365 * years)).strftime("%Y-%m-%dT%H:%M:%SZ")
def build_vc_payload(
learner_ref: str,
path: str,
scenarios_passed: list[str],
rubric_score: float,
completed_weeks: int,
evidence: list[dict[str, Any]] | None,
credential_id: str | None = None,
status_list_index: int | None = None,
) -> dict[str, Any]:
issuance = _now_iso()
issuer = _issuer_url()
cid = credential_id or f"vc-{uuid.uuid4().hex[:16]}"
payload: dict[str, Any] = {
"@context": list(CONTEXTS),
"id": f"{issuer}/vc/{cid}",
"type": ["VerifiableCredential", "MasteryCredential"],
"issuer": issuer,
"validFrom": issuance,
"validUntil": _valid_until(issuance, 3),
"name": f"Mastery of {path.replace('-', ' ').title()}",
"description": (
"Praxis v0.3 formative mastery credential — the holder demonstrated "
"competency across varied scenarios, scored against a 5-level rubric."
),
"credentialTier": CREDENTIAL_TIER,
"credentialSubject": {
"id": f"urn:uuid:{learner_ref}",
"type": "Person",
"skill": path,
"level": "mastery",
"path": path,
"completedWeeks": completed_weeks,
"rubricScore": round(float(rubric_score), 3),
"rubricMax": 5.0,
"rubricThreshold": 3.5,
"scenariosPassed": list(scenarios_passed),
"credentialTier": CREDENTIAL_TIER,
"evidence": evidence or [],
},
}
if status_list_index is not None:
payload["credentialStatus"] = {
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": str(status_list_index),
"statusListCredential": f"{issuer}/status/default",
}
return payload
def canonicalize(payload: dict[str, Any]) -> bytes:
return canonicaljson.encode_canonical_json(payload)
def _build_proof_config(key_id: str) -> dict[str, Any]:
issuer = _issuer_url()
return {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": _now_iso(),
"verificationMethod": f"{issuer}/keys/{key_id}",
"proofPurpose": "assertionMethod",
}
def _compute_hash_data(
unsecured_doc: dict[str, Any], proof_options: dict[str, Any]
) -> bytes:
canonical_doc = canonicalize(unsecured_doc)
canonical_proof = canonicalize(proof_options)
return hashlib.sha256(canonical_proof).digest() + hashlib.sha256(
canonical_doc
).digest()
def sign(payload: dict[str, Any], signing_key: nacl.signing.SigningKey, key_id: str) -> tuple[dict[str, Any], str]:
proof_options = _build_proof_config(key_id)
hash_data = _compute_hash_data(payload, proof_options)
signed = signing_key.sign(hash_data)
signature_bytes = signed.signature
signature_b64 = base64.b64encode(signature_bytes).decode("ascii")
proof = dict(proof_options)
proof["proofValue"] = signature_b64
secured = dict(payload)
secured["proof"] = proof
return secured, signature_b64
def verify_proof(
secured_doc: dict[str, Any],
verify_key: nacl.signing.VerifyKey,
) -> bool:
if "proof" not in secured_doc:
return False
proof = secured_doc["proof"]
proof_value_b64 = proof.get("proofValue")
if not proof_value_b64:
return False
proof_options = {k: v for k, v in proof.items() if k != "proofValue"}
unsecured = {k: v for k, v in secured_doc.items() if k != "proof"}
hash_data = _compute_hash_data(unsecured, proof_options)
try:
sig = base64.b64decode(proof_value_b64)
verify_key.verify(hash_data, sig)
return True
except Exception:
return False
def extract_key_id(secured_doc: dict[str, Any]) -> str | None:
proof = secured_doc.get("proof") or {}
vm = proof.get("verificationMethod") or ""
if "/" in vm:
return vm.rsplit("/", 1)[-1]
return None
async def issue_credential(
store: PraxisStore,
signing_key: nacl.signing.SigningKey | None = None,
learner_id: str = "",
path: str = "",
scenarios_passed: list[str] | None = None,
rubric_score: float = 0.0,
completed_weeks: int = 6,
evidence: list[dict[str, Any]] | None = None,
key_id: str | None = None,
) -> str:
if signing_key is None or key_id is None:
kp, _enc = await get_active_signing_key(store)
signing_key = kp.signing_key
key_id = kp.key_id
scenarios = list(scenarios_passed or [])
ev = list(evidence or [])
status_list = BitstringStatusList(store, "default")
slot = await status_list.allocate_slot()
cred_id = f"vc-{uuid.uuid4().hex[:16]}"
payload = build_vc_payload(
learner_ref=learner_id,
path=path,
scenarios_passed=scenarios,
rubric_score=rubric_score,
completed_weeks=completed_weeks,
evidence=ev,
credential_id=cred_id,
status_list_index=slot,
)
secured, signature_b64 = sign(payload, signing_key, key_id)
payload_json = json.dumps(secured, sort_keys=True, separators=(",", ":"))
await store.insert_credential(cred_id, learner_id, payload_json, signature_b64)
return cred_id
__all__ = [
"build_vc_payload",
"canonicalize",
"sign",
"verify_proof",
"extract_key_id",
"issue_credential",
"CREDENTIAL_TIER",
]
+128
View File
@@ -0,0 +1,128 @@
"""Ed25519 issuer key management (SLICE-09 TASK-09-02).
Private keys are encrypted at rest with nacl.SecretBox using a root key
from env (D-042). Public keys are stored as base64 strings and served
publicly for verification. Key rotation = generate new key, mark old
key as superseded (NOT deleted — old VCs still verify against archived
public keys).
"""
from __future__ import annotations
import base64
import os
import uuid
from dataclasses import dataclass
import nacl.secret
import nacl.signing
import nacl.utils
from db.store import PraxisStore
_SECRETBOX_KEY_BYTES = nacl.secret.SecretBox.KEY_SIZE
def _load_root_key() -> bytes:
raw = os.environ.get("PRAXIS_VC_ISSUER_KEY", "")
if raw:
kb = raw.encode("utf-8")
if len(kb) >= _SECRETBOX_KEY_BYTES:
return kb[:_SECRETBOX_KEY_BYTES]
return nacl.utils.random(_SECRETBOX_KEY_BYTES)
@dataclass
class KeyPair:
key_id: str
signing_key: nacl.signing.SigningKey
verify_key: nacl.signing.VerifyKey
public_key_b64: str
@property
def verification_method(self) -> str:
return _verification_method(self.key_id)
def _verification_method(key_id: str) -> str:
issuer_base = os.environ.get(
"PRAXIS_ISSUER_URL", "https://praxis.example/issuers/v0.3"
)
return f"{issuer_base}/keys/{key_id}"
def _encrypt_private_key(signing_key: nacl.signing.SigningKey, root_key: bytes) -> bytes:
box = nacl.secret.SecretBox(root_key)
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
ciphertext = box.encrypt(bytes(signing_key), nonce)
return ciphertext
def _decrypt_private_key(private_key_enc: bytes, root_key: bytes) -> nacl.signing.SigningKey:
box = nacl.secret.SecretBox(root_key)
seed = box.decrypt(private_key_enc)
return nacl.signing.SigningKey(seed)
async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPair:
rk = root_key if root_key is not None else _load_root_key()
signing_key = nacl.signing.SigningKey.generate()
verify_key = signing_key.verify_key
public_key_b64 = base64.b64encode(bytes(verify_key)).decode("ascii")
private_key_enc = _encrypt_private_key(signing_key, rk)
key_id = f"key-{uuid.uuid4().hex[:12]}"
await store.init_issuer_key(key_id, public_key_b64, private_key_enc)
return KeyPair(key_id, signing_key, verify_key, public_key_b64)
async def get_active_signing_key(
store: PraxisStore, root_key: bytes | None = None
) -> tuple[KeyPair, bytes]:
rk = root_key if root_key is not None else _load_root_key()
row = await store.get_active_signing_key_row()
if row is None:
kp = await init_issuer_key(store, rk)
private_key_enc = await _fetch_private_key_enc(store, kp.key_id)
return kp, private_key_enc
signing_key = _decrypt_private_key(row["private_key_enc"], rk)
verify_key = signing_key.verify_key
kp = KeyPair(row["id"], signing_key, verify_key, row["public_key"])
return kp, row["private_key_enc"]
async def _fetch_private_key_enc(store: PraxisStore, key_id: str) -> bytes:
async with store._connect() as db:
db.row_factory = None
cur = await db.execute(
"SELECT private_key_enc FROM issuer_keys WHERE id = ?", (key_id,)
)
row = await cur.fetchone()
return bytes(row[0]) if row else b""
async def get_public_key_for_verification(
store: PraxisStore, key_id: str
) -> nacl.signing.VerifyKey:
row = await store.get_public_key_row(key_id)
if row is None:
raise KeyError(f"issuer key {key_id} not found")
public_key_bytes = base64.b64decode(row["public_key"])
return nacl.signing.VerifyKey(public_key_bytes)
async def rotate_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPair:
rk = root_key if root_key is not None else _load_root_key()
current = await store.get_active_signing_key_row()
new_kp = await init_issuer_key(store, rk)
if current is not None:
await store.set_issuer_key_superseded(current["id"])
return new_kp
__all__ = [
"KeyPair",
"init_issuer_key",
"get_active_signing_key",
"get_public_key_for_verification",
"rotate_key",
"_verification_method",
]
+75
View File
@@ -0,0 +1,75 @@
"""Bitstring Status List revocation (SLICE-09 TASK-09-03, REQ-NFR-VC-02).
W3C Bitstring Status List v1.0 one bit per issued credential. bit=1 means
revoked. Persisted in SQLite `status_lists` table. Revocation latency = next
verify call (no cache status list fetched from SQLite on every verification,
per REQ-NFR-VC-02). Minimum 131072-bit (16KB) list for herd privacy per spec.
Slot allocation is tracked separately from the revocation bitstring (the
revocation bit is 0 for a newly-issued active credential, so it cannot
distinguish "allocated-active" from "never-allocated"). A parallel allocation
bitstring (`{list_id}_alloc`) records which slots have been handed out.
"""
from __future__ import annotations
from db.store import PraxisStore
_MIN_BITS = 131072
class BitstringStatusList:
def __init__(self, store: PraxisStore, list_id: str = "default") -> None:
self.store = store
self.list_id = list_id
self._alloc_id = f"{list_id}_alloc"
async def _load(self, list_id: str) -> bytearray:
row = await self.store.get_status_list(list_id)
if row is None:
buf = bytearray(_MIN_BITS // 8)
await self.store.upsert_status_list(list_id, bytes(buf), _MIN_BITS)
return buf
return bytearray(row["bitstring"])
async def set_status(self, credential_idx: int, revoked: bool) -> None:
buf = await self._load(self.list_id)
byte_pos = credential_idx >> 3
bit_pos = credential_idx & 7
if revoked:
buf[byte_pos] |= 1 << bit_pos
else:
buf[byte_pos] &= ~(1 << bit_pos)
size = len(buf) * 8
await self.store.upsert_status_list(self.list_id, bytes(buf), size)
async def get_status(self, credential_idx: int) -> bool:
buf = await self._load(self.list_id)
byte_pos = credential_idx >> 3
bit_pos = credential_idx & 7
if byte_pos >= len(buf):
return False
return bool((buf[byte_pos] >> bit_pos) & 1)
async def allocate_slot(self) -> int:
buf = await self._load(self._alloc_id)
for i in range(len(buf) * 8):
byte_pos = i >> 3
bit_pos = i & 7
if not (buf[byte_pos] >> bit_pos) & 1:
buf[byte_pos] |= 1 << bit_pos
size = len(buf) * 8
await self.store.upsert_status_list(
self._alloc_id, bytes(buf), size
)
return i
new_size = (len(buf) * 8) * 2
new_buf = bytearray(new_size // 8)
new_buf[: len(buf)] = buf
idx = len(buf) * 8
new_buf[idx >> 3] |= 1 << (idx & 7)
await self.store.upsert_status_list(self._alloc_id, bytes(new_buf), new_size)
return idx
__all__ = ["BitstringStatusList"]
+117
View File
@@ -0,0 +1,117 @@
"""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"]