f2a12f9fed
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete. Phases: ✓ P0 pre-execution (planning) → v0.1.6 ✓ P1 operator foundation (Postgres+auth+VC migration) → v0.1.7 ✓ P2 cohort dashboard + aggregation → v0.1.8 ✓ P3 final review + ship → v0.1.9 (= v0.4 milestone release) Requirements covered (8/8): REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline), REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard), REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC), REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h) Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041 Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward) Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill) ---ci--- project: praxis phase: 3 milestone: v0.4 status: complete phase_role: final milestone_complete: true milestone_merged_to_main: true tag: v0.1.9 requirements: covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02] partial: [] ---/ci---
152 lines
5.1 KiB
Python
152 lines
5.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
|
|
from typing import Any, Protocol, runtime_checkable
|
|
|
|
import nacl.secret
|
|
import nacl.signing
|
|
import nacl.utils
|
|
from db.store import PraxisStore
|
|
|
|
_SECRETBOX_KEY_BYTES = nacl.secret.SecretBox.KEY_SIZE
|
|
|
|
|
|
@runtime_checkable
|
|
class IssuerKeyStore(Protocol):
|
|
"""Issuer key store protocol (D-051, TASK-04-01).
|
|
|
|
Both PraxisStore (SQLite, v0.3) and PgStore (Postgres, v0.4) implement
|
|
this protocol — R-VC-MIG-03 mitigation (both stores share the same
|
|
interface so verification can use either). The structural check lets
|
|
`isinstance(store, IssuerKeyStore)` succeed for duck-typed stores.
|
|
"""
|
|
|
|
async def init_issuer_key(
|
|
self, key_id: str, public_key: str, private_key_enc: bytes
|
|
) -> None: ...
|
|
|
|
async def get_active_signing_key_row(self) -> dict | None: ...
|
|
|
|
async def get_public_key_row(self, key_id: str) -> dict | None: ...
|
|
|
|
async def set_issuer_key_superseded(self, key_id: str) -> None: ...
|
|
|
|
|
|
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: IssuerKeyStore, 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: IssuerKeyStore, 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: IssuerKeyStore, key_id: str) -> bytes:
|
|
# PraxisStore exposes a _connect() context manager; PgStore does not
|
|
# (it uses a pool). Use the protocol's get_public_key_row which both
|
|
# stores implement, and read private_key_enc from the returned row.
|
|
row = await store.get_public_key_row(key_id)
|
|
if row is None:
|
|
return b""
|
|
enc = row.get("private_key_enc")
|
|
return bytes(enc) if enc is not None else b""
|
|
|
|
|
|
async def get_public_key_for_verification(
|
|
store: IssuerKeyStore, 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__ = [
|
|
"IssuerKeyStore",
|
|
"KeyPair",
|
|
"init_issuer_key",
|
|
"get_active_signing_key",
|
|
"get_public_key_for_verification",
|
|
"rotate_key",
|
|
"_verification_method",
|
|
] |