Files
praxis/server/vc/migrate_keys.py
T
Praxis CI c4c20a3722 feat(P01): SLICE-04 VC issuer key migration SQLite→Postgres (R-VC-MIG-01)
- TASK-04-01 server/vc/issuer_keys.py: refactor to IssuerKeyStore
  Protocol (runtime_checkable). PraxisStore + PgStore both implement it
  (R-VC-MIG-03). Functions now accept IssuerKeyStore instead of
  PraxisStore. _fetch_private_key_enc rewritten to use
  get_public_key_row (protocol method) instead of store._connect()
  (PgStore has no _connect). Backward-compatible — all 19 v0.3 VC
  tests still pass.
- TASK-04-02 db/pg_store.py: IssuerKeyStore methods (already implemented
  in TASK-01-06): init/get_active/get_public_key_row/set_superseded.
  get_public_key_row queries by id (not status) → finds superseded keys
  (R-VC-MIG-01 fallback). db/store.py get_public_key_row now also
  returns private_key_enc (protocol alignment).
- TASK-04-03 server/vc/migrate_keys.py: migrate_issuer_keys() one-time
  procedure. R-VC-MIG-01: archives v0.3 public key as superseded BEFORE
  generating the fresh v0.4 active key (step 2 before step 3). G-027
  first-boot path: no v0.3 active key in SQLite → skip archive, generate
  fresh key only. Idempotent (no-op if Postgres already has an active key).
- TASK-04-04 server/vc/verification.py: verify_credential now accepts
  pg_store + sqlite_store kwargs. G-011 two-store fallback (binding):
  (a) Postgres for key lookup (active + superseded); (b) Postgres for
  credential, fall back to SQLite if not found (v0.3 creds stay in
  SQLite); (c) SQLite-only if no Postgres (v0.3 compat).
- TASK-04-05 tests/test_vc_migration.py: 9 tests — migration archives +
  generates fresh, idempotent, G-027 first-boot, archive-before-active
  ordering (R-VC-MIG-01), v0.3 VC verifies against superseded key in
  Postgres (R-VC-MIG-01 critical), v0.4 VC verifies, tamper detection,
  G-011(b) SQLite fallback, G-011(c) SQLite-only.

---ci---
project: praxis
phase: 1
milestone: v0.4
status: execute
persona: security-engineer
task: 04-01,04-02,04-03,04-04,04-05
requirements:
  covered: [REQ-MT-01]
  grill:
    - G-011 (two-store fallback semantics — explicit in verify_credential)
    - G-027 (first-boot: no v0.3 key → skip archive, fresh key only)
  risks:
    - R-VC-MIG-01 (archived-before-active — tested in test_migration_archives_before_activating_r_vc_mig_01 + test_v03_vc_verifies_against_superseded_key_in_pg)
---/ci---
2026-08-04 00:55:16 +00:00

94 lines
3.7 KiB
Python

"""VC issuer key migration SQLite → Postgres (TASK-04-03, D-051).
One-time migration procedure (R-VC-MIG-01 — highest-severity v0.4 risk):
1. Read the v0.3 active public key from SQLite issuer_keys.
2. Insert that public key into Postgres issuer_keys with status=
'superseded' (private key NOT migrated — only the public key is
archived for verification of already-issued v0.3 VCs).
3. Generate a fresh Ed25519 keypair in Postgres issuer_keys with
status='active' (encrypted at rest with the root key).
4. Return {archived_key_id, new_key_id}.
R-VC-MIG-01 mitigation: the v0.3 public key is archived as superseded
BEFORE the fresh key is activated (step 2 before step 3). This guarantees
v0.3 VCs remain verifiable against the archived key.
G-027 (first-boot path): if SQLite has NO v0.3 active key (fresh deploy),
skip the archive step and only generate the fresh v0.4 keypair.
Idempotent: if Postgres already has an active key, the whole procedure is
a no-op. If Postgres already has a superseded key matching the v0.3 key_id,
skip step 2 (already archived) but still generate the fresh key if no
active key exists.
"""
from __future__ import annotations
import base64
import uuid
from typing import Any
import nacl.signing
from db.pg_store import PgStore
from db.store import PraxisStore
from server.vc.issuer_keys import _encrypt_private_key
async def _archive_v03_public_key(
pg_store: PgStore, v03_key_id: str, v03_public_key: str
) -> None:
"""Insert the v0.3 public key into Postgres as superseded (idempotent)."""
existing = await pg_store.get_public_key_row(v03_key_id)
if existing is not None:
return # already archived (or present as active — leave as-is)
await pg_store.init_issuer_key(v03_key_id, v03_public_key, b"")
await pg_store.set_issuer_key_superseded(v03_key_id)
async def _generate_fresh_v04_key(
pg_store: PgStore, root_key: bytes
) -> str:
"""Generate a fresh Ed25519 keypair in Postgres as active. Returns key_id."""
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, root_key)
key_id = f"key-{uuid.uuid4().hex[:12]}"
await pg_store.init_issuer_key(key_id, public_key_b64, private_key_enc)
return key_id
async def migrate_issuer_keys(
sqlite_store: PraxisStore,
pg_store: PgStore,
root_key: bytes,
) -> dict[str, str | None]:
"""Run the one-time VC key migration. Idempotent.
Returns {"archived_key_id": str | None, "new_key_id": str | None}.
archived_key_id is None on the G-027 first-boot path (no v0.3 key).
new_key_id is None if an active key already existed (no-op).
"""
# If Postgres already has an active key, the whole migration is done.
active = await pg_store.get_active_signing_key_row()
if active is not None:
return {"archived_key_id": None, "new_key_id": None}
# Step 1 (G-027): read v0.3 active public key from SQLite. May be None
# on a fresh deploy with no v0.3 history.
v03_row = await sqlite_store.get_active_signing_key_row()
archived_key_id: str | None = None
if v03_row is not None:
v03_key_id = v03_row["id"]
v03_public_key = v03_row["public_key"]
# Step 2 (R-VC-MIG-01): archive BEFORE activating the fresh key.
await _archive_v03_public_key(pg_store, v03_key_id, v03_public_key)
archived_key_id = v03_key_id
# Step 3: generate the fresh v0.4 keypair as active.
new_key_id = await _generate_fresh_v04_key(pg_store, root_key)
return {"archived_key_id": archived_key_id, "new_key_id": new_key_id}
__all__ = ["migrate_issuer_keys"]