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---
94 lines
3.7 KiB
Python
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"] |