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:
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user