"""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", ]