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---
128 lines
4.1 KiB
Python
128 lines
4.1 KiB
Python
"""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",
|
|
] |