Files
praxis/server/vc/issuer_keys.py
T
Praxis CI 00e39a3f85 feat(milestone): merge phase/01 operator-foundation → milestone/v0.4-operator-tier
Phase 1 complete — Operator Foundation:
- Postgres 16 in Docker-in-LXC (asyncpg pool, 5-table schema, PgStore, migrations)
- Operator auth (argon2id, signed stateless cookies, slowapi 5/min rate limit)
- VC issuer key migration SQLite→Postgres (archive-before-active, R-VC-MIG-01)
- Operator bootstrap CLI (create-operator.py, idempotent)
- Backup cron script + G-008 restore drill
- Graceful degradation (server starts without Postgres)
- 272 tests pass, 33 skip (Postgres-requiring), 0 fail

---ci---
project: praxis
phase: 1
milestone: v0.4
status: complete
requirements:
  covered: [REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02]
  partial: []
---/ci---
2026-08-04 01:41:06 +00:00

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