4d39596a7d
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---
186 lines
5.7 KiB
Python
186 lines
5.7 KiB
Python
"""VC issuer unit tests (SLICE-09 TASK-09-05).
|
|
|
|
Covers: key generation, sign/verify round-trip, tamper detection (flip a byte
|
|
in payload → verify fails), JCS canonicalization determinism (same dict → same
|
|
bytes, run twice), status list set/get, revocation invalidates verification.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import nacl.signing
|
|
import pytest
|
|
|
|
from db.migrate import apply_migrations
|
|
from db.store import PraxisStore
|
|
from server.vc import issuer, issuer_keys
|
|
from server.vc.status_list import BitstringStatusList
|
|
|
|
|
|
def _await(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_db(tmp_path: Path) -> Path:
|
|
return tmp_path / "test_vc.db"
|
|
|
|
|
|
def _make_store(db_path: Path) -> PraxisStore:
|
|
apply_migrations(db_path)
|
|
return PraxisStore(db_path)
|
|
|
|
|
|
def test_init_issuer_key_generates_ed25519_keypair(tmp_db: Path):
|
|
store = _make_store(tmp_db)
|
|
root = b"k" * 32
|
|
kp = _await(issuer_keys.init_issuer_key(store, root))
|
|
assert kp.key_id.startswith("key-")
|
|
assert len(kp.public_key_b64) > 0
|
|
pk_bytes = base64.b64decode(kp.public_key_b64)
|
|
assert len(pk_bytes) == 32
|
|
assert bytes(kp.verify_key) == pk_bytes
|
|
|
|
|
|
def test_sign_verify_round_trip(tmp_db: Path):
|
|
store = _make_store(tmp_db)
|
|
root = b"k" * 32
|
|
kp = _await(issuer_keys.init_issuer_key(store, root))
|
|
payload = issuer.build_vc_payload(
|
|
learner_ref="learner-1",
|
|
path="customer-service",
|
|
scenarios_passed=["s1", "s2", "s3"],
|
|
rubric_score=4.1,
|
|
completed_weeks=6,
|
|
evidence=[{"type": "Evidence", "rubricMean": 4.1}],
|
|
status_list_index=0,
|
|
)
|
|
secured, sig_b64 = issuer.sign(payload, kp.signing_key, kp.key_id)
|
|
assert issuer.verify_proof(secured, kp.verify_key) is True
|
|
sig = base64.b64decode(sig_b64)
|
|
assert len(sig) == 64
|
|
|
|
|
|
def test_tamper_detection_flipped_byte_fails(tmp_db: Path):
|
|
store = _make_store(tmp_db)
|
|
root = b"k" * 32
|
|
kp = _await(issuer_keys.init_issuer_key(store, root))
|
|
payload = issuer.build_vc_payload(
|
|
learner_ref="learner-1",
|
|
path="customer-service",
|
|
scenarios_passed=["s1"],
|
|
rubric_score=3.8,
|
|
completed_weeks=6,
|
|
evidence=[],
|
|
status_list_index=0,
|
|
)
|
|
secured, _ = issuer.sign(payload, kp.signing_key, kp.key_id)
|
|
secured["credentialSubject"]["rubricScore"] = 1.1
|
|
assert issuer.verify_proof(secured, kp.verify_key) is False
|
|
|
|
|
|
def test_tamper_proof_value_fails(tmp_db: Path):
|
|
store = _make_store(tmp_db)
|
|
root = b"k" * 32
|
|
kp = _await(issuer_keys.init_issuer_key(store, root))
|
|
payload = issuer.build_vc_payload(
|
|
learner_ref="learner-1",
|
|
path="customer-service",
|
|
scenarios_passed=["s1"],
|
|
rubric_score=3.8,
|
|
completed_weeks=6,
|
|
evidence=[],
|
|
status_list_index=0,
|
|
)
|
|
secured, sig_b64 = issuer.sign(payload, kp.signing_key, kp.key_id)
|
|
flipped = bytearray(base64.b64decode(sig_b64))
|
|
flipped[0] ^= 0x01
|
|
secured["proof"]["proofValue"] = base64.b64encode(bytes(flipped)).decode("ascii")
|
|
assert issuer.verify_proof(secured, kp.verify_key) is False
|
|
|
|
|
|
def test_jcs_canonicalization_determinism():
|
|
d = {
|
|
"b": 2,
|
|
"a": 1,
|
|
"nested": {"z": [3, 2, 1], "y": "hello"},
|
|
}
|
|
c1 = issuer.canonicalize(d)
|
|
c2 = issuer.canonicalize(d)
|
|
assert c1 == c2
|
|
parsed = json.loads(c1.decode("utf-8"))
|
|
assert parsed == {"a": 1, "b": 2, "nested": {"y": "hello", "z": [3, 2, 1]}}
|
|
|
|
|
|
def test_jcs_key_ordering_is_sorted():
|
|
d = {"zeta": 1, "alpha": 2, "mid": 3}
|
|
c = issuer.canonicalize(d)
|
|
text = c.decode("utf-8")
|
|
assert text.index('"alpha"') < text.index('"mid"') < text.index('"zeta"')
|
|
|
|
|
|
def test_status_list_set_get_round_trip(tmp_db: Path):
|
|
store = _make_store(tmp_db)
|
|
sl = BitstringStatusList(store, "default")
|
|
_await(sl.set_status(5, True))
|
|
assert _await(sl.get_status(5)) is True
|
|
assert _await(sl.get_status(6)) is False
|
|
_await(sl.set_status(5, False))
|
|
assert _await(sl.get_status(5)) is False
|
|
|
|
|
|
def test_status_list_allocate_slot_returns_free_index(tmp_db: Path):
|
|
store = _make_store(tmp_db)
|
|
sl = BitstringStatusList(store, "default")
|
|
s1 = _await(sl.allocate_slot())
|
|
s2 = _await(sl.allocate_slot())
|
|
assert s1 == 0
|
|
assert s2 == 1
|
|
|
|
|
|
def test_revocation_invalidates_verification(tmp_db: Path):
|
|
store = _make_store(tmp_db)
|
|
root = b"k" * 32
|
|
kp = _await(issuer_keys.init_issuer_key(store, root))
|
|
cred_id = _await(
|
|
issuer.issue_credential(
|
|
store=store,
|
|
signing_key=kp.signing_key,
|
|
key_id=kp.key_id,
|
|
learner_id="learner-1",
|
|
path="customer-service",
|
|
scenarios_passed=["s1", "s2", "s3"],
|
|
rubric_score=4.1,
|
|
completed_weeks=6,
|
|
evidence=[{"type": "Evidence", "rubricMean": 4.1}],
|
|
)
|
|
)
|
|
row = _await(store.get_credential(cred_id))
|
|
assert row is not None
|
|
secured = json.loads(row["vc_payload_json"])
|
|
assert issuer.verify_proof(secured, kp.verify_key) is True
|
|
cs = secured["credentialStatus"]
|
|
idx = int(cs["statusListIndex"])
|
|
sl = BitstringStatusList(store, "default")
|
|
_await(sl.set_status(idx, True))
|
|
_await(store.set_credential_status(cred_id, "revoked"))
|
|
revoked = _await(sl.get_status(idx))
|
|
assert revoked is True
|
|
|
|
|
|
def test_credential_tier_is_formative_in_payload():
|
|
payload = issuer.build_vc_payload(
|
|
learner_ref="learner-1",
|
|
path="customer-service",
|
|
scenarios_passed=["s1"],
|
|
rubric_score=4.0,
|
|
completed_weeks=6,
|
|
evidence=[],
|
|
status_list_index=0,
|
|
)
|
|
assert payload["credentialTier"] == "formative"
|
|
assert payload["credentialSubject"]["credentialTier"] == "formative" |